Skip to main content

aion_core/
cluster_event.rs

1//! Cluster topology and ownership events for the ops console real-time channel (WS3).
2//!
3//! This module defines the *typed contract* for the second class of real-time push the ops console
4//! consumes: cluster/topology/ownership deltas that today exist only as `tracing` logs and
5//! Prometheus gauges. The wire shapes live in `aion-core` (not `aion-server`) for one reason: only
6//! this leaf crate depends on `ts-rs`, so this is the single place a Rust type can cross the
7//! Rust -> TypeScript boundary into the ops console's generated bindings.
8//!
9//! # Scope (WS3 FOUNDATION)
10//!
11//! This file is *types only*. It deliberately does **not** define the broadcast publisher, the
12//! emit call-sites in the supervisor / registry, the subscription endpoint, or the namespace gate.
13//! Those are wired in later WS3 increments. Defining the contract first lets the ops console and the
14//! server agree on shapes before any behaviour ships.
15//!
16//! # Honesty corrections applied to the original design
17//!
18//! The design table named several variants whose payloads cannot be sourced from the subsystems
19//! that would emit them today. Rather than ship a type whose fields are guaranteed to be faked,
20//! those variants are **descoped from Phase 1** and documented here so the omission is explicit and
21//! not silently rediscovered at wiring time:
22//!
23//! - **`NodeMetricsSampled` — DEFERRED.** `aion-server`'s metrics (`observability/metrics.rs`) have
24//!   no `node` dimension (`connected_workers` is an `IntGaugeVec` keyed by *namespace*) and there
25//!   is no `workflows_running` gauge at all. A `{ node, connected_workers, workflows_running }`
26//!   payload cannot be produced by "piggybacking the gauge setters". Sourcing it honestly requires
27//!   a real metrics change (node-labelled gauges + a running gauge). Until then the ops console
28//!   derives `connected_workers` client-side from [`ClusterEvent::WorkerConnected`] /
29//!   [`ClusterEvent::WorkerDisconnected`] deltas against the [`ClusterSnapshot`] baseline. Adding
30//!   a timer that scrapes Prometheus to fill this variant is the exact polling-as-push regression
31//!   WS3 exists to remove, so the variant is omitted rather than tempting that shortcut.
32//! - **`ShardOwnerChanged` / `FencedCasRejected` — DEFERRED (fast-follow).** The haematite seam
33//!   (`HaematiteStore::publish_ln(shard)`) carries only `shard`; the fenced-CAS reject is a
34//!   `haematite::DatabaseError::Fenced { .. }` destructured with `..` and mapped to
35//!   `StoreError::NotOwner { shard }`, carrying only `shard` — never the owner name or the
36//!   attempted/current epochs. The proposed payloads require enriching haematite's `Fenced` error
37//!   to surface owner identity and epoch, which is a cross-crate (likely cross-repo) change. The
38//!   cluster map is still *honest* without these two: shard adoption is fully observable from the
39//!   supervisor's `tick()` ([`ClusterEvent::ShardAdopted`] and friends). Only the store-side
40//!   CAS-reject detail is delayed.
41//!
42//! # u64 precision across the TS boundary
43//!
44//! The ts-rs config exports every `u64` as TS `number` (`with_large_int("number")` in
45//! `generated_types.rs`), which truncates above `2^53`. [`ClusterEventMeta::cluster_seq`] and the
46//! epoch fields below are `u64`. This is the *same* accepted ceiling that already applies to
47//! [`crate::EventEnvelope::seq`]; cluster sequencing follows the established project convention
48//! rather than introducing a divergent string encoding. The ceiling is documented on each field so
49//! the gap-detection math on the client is aware of the bound. A long-lived deployment must keep
50//! `cluster_seq` below `2^53`; in practice this is never reached for a topology-event counter.
51
52use chrono::{DateTime, Utc};
53use serde::{Deserialize, Serialize};
54
55/// Metadata stamped on every [`ClusterEvent`], mirroring [`crate::EventEnvelope`] for the cluster
56/// channel.
57#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
58pub struct ClusterEventMeta {
59    /// Monotonic sequence number assigned by the cluster publisher (a single deployment-global
60    /// `AtomicU64`). The client uses this for gap detection and `after_seq` reconnect dedup, the
61    /// cluster analog of [`crate::EventEnvelope::seq`].
62    ///
63    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
64    pub cluster_seq: u64,
65    /// UTC wall-clock instant at which the originating subsystem observed the state change.
66    pub observed_at: DateTime<Utc>,
67}
68
69/// Transport a connected worker is delivered to over.
70///
71/// Mirrors the live `aion_server::worker::registry::WorkerDelivery` discriminants without carrying
72/// the (non-serializable) delivery channels, so it can cross the wire and the ts-rs boundary.
73#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
74#[serde(tag = "transport")]
75pub enum WorkerTransport {
76    /// gRPC bidirectional-stream delivery (the default transport).
77    Grpc,
78    /// Liminal server-push delivery (feature-gated on the server).
79    Liminal,
80}
81
82/// Why a worker left the connected set.
83///
84/// NOTE (wiring honesty): the single registry deregistration site
85/// (`ConnectedWorkerRegistry::deregister` / `remove_worker`) does not today distinguish a transport
86/// disconnect from an idle timeout from an explicit deregister. The emit increment MUST derive the
87/// reason at the call site from real signal (e.g. a closed delivery channel vs an explicit
88/// deregister RPC vs a liveness-timeout sweep) or collapse to the variant it can actually prove.
89/// This enum defines the *contract*; it must not be populated with a fabricated distinction.
90#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
91#[serde(tag = "reason")]
92pub enum WorkerDeathReason {
93    /// The worker's delivery transport dropped (stream/connection closed).
94    Disconnect,
95    /// The worker was removed by a liveness/idle-timeout sweep.
96    Timeout,
97    /// The worker explicitly deregistered.
98    Deregistered,
99}
100
101/// Operator-requested lifecycle state for a durable worker deployment.
102#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case")]
104pub enum DesiredState {
105    /// The deployment should run.
106    Running,
107    /// The deployment should remain stopped.
108    Stopped,
109}
110
111impl DesiredState {
112    /// Stable human-readable state token.
113    #[must_use]
114    pub const fn token(self) -> &'static str {
115        match self {
116            Self::Running => "running",
117            Self::Stopped => "stopped",
118        }
119    }
120}
121
122/// Create-or-replace result exposed on the cluster and HTTP wires.
123#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
124#[serde(rename_all = "kebab-case")]
125pub enum PutOutcome {
126    /// No record previously existed for the key.
127    Created,
128    /// A record already existed for the key and was replaced.
129    Replaced,
130}
131
132/// Deployment-record lookup state captured when a worker registers.
133#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
134#[serde(rename_all = "kebab-case")]
135pub enum DeploymentAssociation {
136    /// The named deployment record existed.
137    Known,
138    /// The deployment store was checked and the named record was absent.
139    Absent,
140    /// No deployment store was attached, so existence was not checked.
141    Unchecked,
142}
143
144/// A cluster/topology/ownership delta pushed over the ops console real-time channel.
145///
146/// Tagged union on `type` to match the existing [`crate::Event`] wire shape. Every variant carries
147/// [`ClusterEventMeta`] as `meta`. Cluster events are deployment-scoped, not namespace-stamped at
148/// the envelope level; the `Worker*` variants carry their own `namespaces` so the server-side gate
149/// can intersect them against the caller's grants (see the deferred `cluster_filter`).
150#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
151#[serde(tag = "type")]
152pub enum ClusterEvent {
153    /// A peer was added to this node's watch set (cluster-membership grew mid-session).
154    ///
155    /// Distinct from [`Self::PeerConnected`]: this is a *topology* change (a new peer to watch),
156    /// not a *liveness* transition. Without it, a peer that joins after the priming
157    /// [`ClusterSnapshot`] would silently never appear on the map until the next snapshot.
158    PeerAdded {
159        /// Cluster-event metadata.
160        meta: ClusterEventMeta,
161        /// The newly-watched peer's node name.
162        peer_name: String,
163        /// The peer's forwarding address, when known.
164        forward_addr: Option<String>,
165    },
166    /// A watched peer transitioned from down/unknown to connected (a liveness recovery).
167    ///
168    /// EMIT NOTE: the supervisor `tick()` resets `consecutive_down`/`adopted` on the same tick it
169    /// observes `connected`, so the recovery condition (`was_down = consecutive_down > 0 ||
170    /// adopted`) MUST be captured *before* that reset and the emit driven by the captured value.
171    /// Emitting after the reset produces no recovery events (every tick looks freshly connected).
172    PeerConnected {
173        /// Cluster-event metadata.
174        meta: ClusterEventMeta,
175        /// The recovered peer's node name.
176        peer_name: String,
177        /// The peer's forwarding address, when known.
178        forward_addr: Option<String>,
179    },
180    /// A watched peer was observed down on a supervisor tick.
181    ///
182    /// Emitted on every tick the peer is observed down; `confirmed` flips to `true` once
183    /// `consecutive_down >= confirmations` (the debounce threshold that authorizes adoption).
184    PeerDisconnected {
185        /// Cluster-event metadata.
186        meta: ClusterEventMeta,
187        /// The down peer's node name.
188        peer_name: String,
189        /// Consecutive ticks this peer has been observed down.
190        consecutive_down: u32,
191        /// Whether the debounce threshold has been crossed (adoption-eligible).
192        confirmed: bool,
193    },
194    /// This node adopted shards previously owned by a failed peer.
195    ShardAdopted {
196        /// Cluster-event metadata.
197        meta: ClusterEventMeta,
198        /// Shard indices adopted in this transition.
199        shards: Vec<usize>,
200        /// The peer the shards were adopted from.
201        from_peer: String,
202        /// This node's name (the new owner).
203        adopted_by: String,
204    },
205    /// An adoption attempt failed and will be retried on a subsequent tick.
206    ///
207    /// HONESTY: the original design hardcoded `will_retry: true`. That is a silent lie — a peer
208    /// later observed connected resets `adopted` and stops retrying, and the
209    /// "handled elsewhere" path terminally stops. The retry decision is not expressible as a
210    /// constant, so the field is omitted; the ops console infers retry by observing whether a
211    /// subsequent [`Self::ShardAdopted`] / [`Self::ShardAdoptionSkipped`] for the same
212    /// `(from_peer, shards)` arrives, rather than trusting a promise that may never be kept
213    /// (ADR-016 no-silent-failure).
214    ShardAdoptionFailed {
215        /// Cluster-event metadata.
216        meta: ClusterEventMeta,
217        /// Shard indices the failed attempt targeted.
218        shards: Vec<usize>,
219        /// The peer the shards would have been adopted from.
220        from_peer: String,
221        /// Human-readable adoption error.
222        error: String,
223    },
224    /// Adoption was skipped because the shards are already held by a live third-party owner.
225    ShardAdoptionSkipped {
226        /// Cluster-event metadata.
227        meta: ClusterEventMeta,
228        /// Shard indices that were skipped.
229        shards: Vec<usize>,
230        /// The peer the shards would have been adopted from.
231        from_peer: String,
232        /// The live node currently holding the shards.
233        held_by: String,
234    },
235    /// A worker joined the connected set.
236    WorkerConnected {
237        /// Cluster-event metadata.
238        meta: ClusterEventMeta,
239        /// Stable worker identifier.
240        worker_id: String,
241        /// Namespaces this worker serves.
242        namespaces: Vec<String>,
243        /// Task-queue pool this worker serves within its namespaces.
244        task_queue: String,
245        /// Delivery transport for this worker.
246        transport: WorkerTransport,
247        /// Locality/node label, when the worker reported one.
248        node: Option<String>,
249        /// Deployment named by the worker's instance identity, when supplied.
250        deployment: Option<String>,
251        /// Lookup state for the named deployment. `None` means the worker
252        /// supplied no instance identity.
253        deployment_association: Option<DeploymentAssociation>,
254    },
255    /// A worker left the connected set.
256    WorkerDisconnected {
257        /// Cluster-event metadata.
258        meta: ClusterEventMeta,
259        /// Stable worker identifier.
260        worker_id: String,
261        /// Namespaces this worker served.
262        namespaces: Vec<String>,
263        /// Why the worker left (see [`WorkerDeathReason`] wiring note).
264        reason: WorkerDeathReason,
265    },
266    /// The cluster supervisor started on this node (lifecycle).
267    ///
268    /// Lets the calm-state view (ADR-019) distinguish "supervisor running, all peers healthy" from
269    /// "supervisor not running".
270    SupervisorStarted {
271        /// Cluster-event metadata.
272        meta: ClusterEventMeta,
273        /// This node's name.
274        node: String,
275    },
276    /// The cluster supervisor stopped on this node (clean drain / shutdown).
277    SupervisorStopped {
278        /// Cluster-event metadata.
279        meta: ClusterEventMeta,
280        /// This node's name.
281        node: String,
282    },
283    /// A brand-new namespace was minted in the durable registry (Control-Plane
284    /// Phase 1, S8).
285    ///
286    /// Emitted exactly once per genuinely-new namespace at the registry's single
287    /// `MintOutcome::Created` choke-point — so the worker-register seam (S5), the
288    /// workflow-start safety net (S6), and the explicit `POST /namespaces` path
289    /// (S7) all surface the same delta. An idempotent re-reference of an existing
290    /// namespace (`MintOutcome::AlreadyExisted`) never emits this, so the ops
291    /// console appends each namespace exactly once with no refresh.
292    ///
293    /// `origin` is the stable `snake_case` label (`worker_mint` / `start_mint` /
294    /// `explicit` / `inferred_from_state`) rather than the `aion-store`
295    /// `NamespaceOrigin` enum, because this leaf crate must not depend on the
296    /// store crate; the label is the same string the audit `tracing` event logs.
297    /// `created_at` is the durable record's first-mint instant, carried so the
298    /// console's created column matches the registry without a follow-up fetch.
299    NamespaceCreated {
300        /// Cluster-event metadata.
301        meta: ClusterEventMeta,
302        /// The minted namespace's name (registry primary key).
303        name: String,
304        /// The durable record's creation instant (its `created_at`).
305        created_at: DateTime<Utc>,
306        /// How the namespace came to exist, as the stable `snake_case` label.
307        origin: String,
308    },
309    /// A namespace's durable placement directive was changed (Control-Plane
310    /// Phase 2, P2-P2 — `PUT /namespaces/{name}/placement`).
311    ///
312    /// Emitted on the SAME deploy-scoped channel as [`Self::NamespaceCreated`]
313    /// after the placement is durably set, so the ops console's namespace panel
314    /// reflects an operator's placement change live with no refresh. `placement`
315    /// is the stable wire projection ([`NamespacePlacementWire`]) of the durable
316    /// `NamespacePlacement`, carried as a `kind` label + label set rather than the
317    /// `aion-store` enum, because this leaf crate must not depend on the store
318    /// crate (the same discipline [`Self::NamespaceCreated`] applies to `origin`).
319    NamespacePlacementChanged {
320        /// Cluster-event metadata.
321        meta: ClusterEventMeta,
322        /// The namespace whose placement changed (registry primary key).
323        name: String,
324        /// The new placement directive, as the stable wire projection.
325        placement: NamespacePlacementWire,
326    },
327    /// A periodic snapshot of a namespace's concurrency-quota state (Control-Plane
328    /// Phase 2, P2-Q3), pushed on the SAME deploy-scoped channel as
329    /// [`Self::NamespaceCreated`] so the ops console renders a live "in-flight /
330    /// ceiling" badge that ticks as work flows.
331    ///
332    /// This is the ONE edge-triggered exception's honest counterweight: unlike the
333    /// other variants (each emitted at a real subsystem mutation), quota state
334    /// changes on every claim/settle, so per-row emission would be a firehose.
335    /// Instead a throttled snapshot task samples each active namespace's REAL
336    /// durable state once per cadence and emits this — a periodic snapshot per
337    /// active namespace, never a client poll. The console folds the latest snapshot
338    /// per namespace, superseding the prior value.
339    ///
340    /// `in_flight` is the durable **Claimed** outbox-row count for the namespace
341    /// (`count_claimed_outbox_rows`) — the SAME notion the keyed backpressure caps,
342    /// never the dead `inflight_activities` gauge (P2-Q0). `ceiling` is the tenant's
343    /// **cluster-wide** contract: its explicit `max_in_flight_activities` override
344    /// or the platform default — the number the operator set, not the per-node
345    /// proportional slice (exposing per-node math is the leaky-abstraction footgun
346    /// §3.6 rejects). On a single node the durable `in_flight` this node sees is the
347    /// whole cluster's; multi-node exact aggregation is the reserved §3.6 follow-up.
348    NamespaceQuotaState {
349        /// Cluster-event metadata.
350        meta: ClusterEventMeta,
351        /// The namespace this quota snapshot describes (registry primary key).
352        namespace: String,
353        /// Durable count of currently-**Claimed** outbox rows for the namespace —
354        /// the in-flight activities the ceiling caps.
355        ///
356        /// Exported to TypeScript as `number`; see the module docs for the accepted
357        /// `2^53` ceiling (an in-flight count never approaches it).
358        in_flight: u64,
359        /// The tenant's cluster-wide concurrency ceiling: its explicit
360        /// `max_in_flight_activities` override, or the platform default when unset.
361        ceiling: u32,
362    },
363    /// A worker deployment was durably created or replaced by an operator.
364    WorkerDeploymentPut {
365        /// Cluster-event metadata.
366        meta: ClusterEventMeta,
367        /// Durable deployment name.
368        name: String,
369        /// Typed create-or-replace outcome.
370        outcome: PutOutcome,
371        /// Typed desired state.
372        desired_state: DesiredState,
373        /// Version captured from the deployed server binary.
374        binary_version: String,
375        /// SHA-256 captured from the deployed server executable bytes.
376        binary_content_hash: String,
377    },
378    /// An operator durably changed a deployment's desired state.
379    WorkerDeploymentDesiredStateChanged {
380        /// Cluster-event metadata.
381        meta: ClusterEventMeta,
382        /// Durable deployment name.
383        name: String,
384        /// Typed desired state.
385        desired_state: DesiredState,
386    },
387    /// An operator durably deleted a deployment record.
388    WorkerDeploymentDeleted {
389        /// Cluster-event metadata.
390        meta: ClusterEventMeta,
391        /// Deleted deployment name.
392        name: String,
393    },
394}
395
396/// Stable wire projection of a namespace's durable placement directive
397/// (`NamespacePlacement` in `aion-store`), for the ops-console real-time channel
398/// (Control-Plane Phase 2).
399///
400/// Lives in this leaf crate (not `aion-server` / `aion-store`) for the same reason
401/// the rest of [`ClusterEvent`] does: only this crate crosses the Rust ->
402/// TypeScript boundary via `ts-rs`. `kind` is the stable `snake_case` variant tag
403/// (`unplaced` / `prefer` / `pinned`) and `nodes` is the (possibly empty)
404/// node-label set; `Unplaced` carries an empty `nodes`. Modelling it as a flat
405/// `{kind, nodes}` shape rather than re-encoding the store's tagged form keeps the
406/// generated TS binding a single simple type the console can switch on.
407#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
408pub struct NamespacePlacementWire {
409    /// Stable `snake_case` placement-kind tag: `unplaced` / `prefer` / `pinned`.
410    pub kind: String,
411    /// The placement's node-label set, deterministically ordered. Empty for
412    /// `unplaced`; the preferred/required labels for `prefer`/`pinned`.
413    pub nodes: Vec<String>,
414}
415
416/// A peer entry in the priming [`ClusterSnapshot`].
417#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
418pub struct ClusterPeer {
419    /// The peer's node name.
420    pub peer_name: String,
421    /// The peer's forwarding address, when known.
422    pub forward_addr: Option<String>,
423    /// Whether the peer is currently observed connected.
424    pub connected: bool,
425    /// Consecutive ticks observed down (0 when connected).
426    pub consecutive_down: u32,
427}
428
429/// A shard ownership entry in the priming [`ClusterSnapshot`].
430#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
431pub struct ClusterShard {
432    /// Shard index.
433    pub shard: usize,
434    /// The node that currently owns the shard.
435    pub owner: String,
436    /// The epoch fence value at which the owner holds the shard.
437    ///
438    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
439    pub epoch: u64,
440}
441
442/// A connected-worker entry in the priming [`ClusterSnapshot`].
443#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
444pub struct ClusterWorker {
445    /// Stable worker identifier.
446    pub worker_id: String,
447    /// Namespaces this worker serves (already intersected with the caller's grants by the gate).
448    pub namespaces: Vec<String>,
449    /// Task-queue pool this worker serves.
450    pub task_queue: String,
451    /// Delivery transport for this worker.
452    pub transport: WorkerTransport,
453    /// Locality/node label, when reported.
454    pub node: Option<String>,
455    /// Deployment named by the worker's instance identity, when supplied.
456    pub deployment: Option<String>,
457    /// Lookup state for that deployment. `None` for workers without instance
458    /// identity.
459    pub deployment_association: Option<DeploymentAssociation>,
460}
461
462/// A durable worker-deployment entry in the priming cluster snapshot.
463#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
464pub struct ClusterDeployment {
465    /// Durable deployment name.
466    pub name: String,
467    /// Typed desired state.
468    pub desired_state: DesiredState,
469    /// Version captured at deploy time.
470    pub binary_version: String,
471    /// SHA-256 captured at deploy time.
472    pub binary_content_hash: String,
473}
474
475/// A calm-state baseline of the whole cluster, sent as the priming reply before the live delta
476/// stream so the ops console can render an at-a-glance "all clear" before any [`ClusterEvent`]
477/// arrives (ADR-019). On `cluster_lagged` the client re-requests this rather than replaying a
478/// (non-durable) delta history.
479#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
480pub struct ClusterSnapshot {
481    /// The reading node's own name (self-identity; baselines the `Peer*` deltas which describe
482    /// *other* nodes).
483    pub node: String,
484    /// The `cluster_seq` this snapshot is consistent as-of; the client applies only deltas with a
485    /// strictly greater `cluster_seq`.
486    ///
487    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
488    pub as_of_seq: u64,
489    /// Watched peers and their current liveness.
490    pub peers: Vec<ClusterPeer>,
491    /// Shards owned by (or visible to) the reading node.
492    pub shards: Vec<ClusterShard>,
493    /// Connected workers, already gated to the caller's namespaces.
494    pub workers: Vec<ClusterWorker>,
495    /// Durable worker deployments visible under the deploy grant.
496    pub deployments: Vec<ClusterDeployment>,
497}
498
499/// A command the ops console can issue against the cluster channel (ADR-020 command seam).
500///
501/// Defined now for contract coherence. **Phase 1 ships only [`Self::RequestClusterSnapshot`]** (a
502/// read). The mutating variants compile so the contract exists, but their handlers reject with an
503/// `unimplemented` wire error — and, per ADR-020, MUST still run the full auth gate
504/// (`caller.deploy_granted()`) *before* rejecting, so the seam's authorization contract is
505/// exercised now and an `unimplemented` stub is never an auth-bypass-shaped hole.
506///
507/// Tagged on `command` (distinct from [`ClusterEvent`]'s `type`) because commands and events are
508/// different directions on the wire; the ops console's protocol parser keys command frames on
509/// `command` and event frames on `type`.
510#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
511#[serde(tag = "command")]
512pub enum ClusterCommand {
513    /// Read the current cluster baseline (peers + shards + workers). Read-only; needs only
514    /// namespace-or-deploy scope. **Phase 1.**
515    RequestClusterSnapshot {},
516    /// Cancel a running workflow. Aspirational — handler returns `unimplemented`.
517    CancelWorkflow {
518        /// Owning namespace.
519        namespace: String,
520        /// Target workflow.
521        workflow_id: String,
522    },
523    /// Reopen a failed/closed workflow. Aspirational — handler returns `unimplemented`.
524    ReopenWorkflow {
525        /// Owning namespace.
526        namespace: String,
527        /// Target workflow.
528        workflow_id: String,
529    },
530    /// Redrive a single outbox row. Aspirational — handler returns `unimplemented`.
531    RedriveOutboxRow {
532        /// Owning namespace.
533        namespace: String,
534        /// Target workflow.
535        workflow_id: String,
536        /// Outbox row ordinal.
537        ordinal: u64,
538    },
539    /// Drain a node (stop-new-work + finish-in-flight + safe shutdown). Aspirational.
540    DrainNode {
541        /// Target node name.
542        node: String,
543    },
544    /// Planned epoch-fenced shard handoff to a target node. Aspirational.
545    PlannedHandoff {
546        /// Shard to move.
547        shard: usize,
548        /// Destination node.
549        target_node: String,
550    },
551    /// Test-only chaos kill of a node. Aspirational (gated).
552    ChaosKillNode {
553        /// Target node name.
554        node: String,
555    },
556}
557
558/// A typed terminal error on the cluster channel.
559///
560/// Mirrors the workflow path's lagged contract: when a subscriber falls behind the bounded cluster
561/// broadcast buffer the server sends exactly one of these and closes, carrying the skipped count so
562/// the client can decide snapshot-vs-resume (it always re-requests a [`ClusterSnapshot`], since
563/// there is no durable cluster history). Surfaced to the UI as a typed error, never silently
564/// dropped (ADR-016).
565#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
566#[serde(tag = "kind")]
567pub enum ClusterStreamError {
568    /// The subscriber lagged past the bounded broadcast buffer; `skipped` deltas were dropped.
569    ClusterLagged {
570        /// Number of cluster events dropped because the subscriber fell behind.
571        ///
572        /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
573        skipped: u64,
574    },
575}