Skip to main content

starweaver_session/
management.rs

1//! Product-neutral agent-facing session-management contracts.
2//!
3//! These types deliberately contain no product services or transport details. A host constructs
4//! [`AgentSessionScope`] and implements the narrow handles in `starweaver-agent`.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use starweaver_core::{RunId, SessionId};
12use starweaver_stream::{DisplayMessage, ReplayCursor};
13
14use crate::{InputPart, RunStatus, SessionStatus};
15
16/// Backward-compatible namespace used by single-user local products.
17pub const LOCAL_SESSION_NAMESPACE: &str = "local";
18
19/// Composite identity of a durable session.
20#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
21pub struct ManagedSessionTarget {
22    /// Host-derived tenant/store namespace.
23    pub namespace_id: String,
24    /// Durable session id within the namespace.
25    pub session_id: SessionId,
26}
27
28impl ManagedSessionTarget {
29    /// Build a composite session target.
30    #[must_use]
31    pub fn new(namespace_id: impl Into<String>, session_id: SessionId) -> Self {
32        Self {
33            namespace_id: namespace_id.into(),
34            session_id,
35        }
36    }
37}
38
39/// Composite identity of a durable run. A run id is never globally unique.
40#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
41pub struct ManagedRunTarget {
42    /// Host-derived tenant/store namespace.
43    pub namespace_id: String,
44    /// Owning session id.
45    pub session_id: SessionId,
46    /// Session-scoped run id.
47    pub run_id: RunId,
48}
49
50impl ManagedRunTarget {
51    /// Build a composite run target.
52    #[must_use]
53    pub fn new(namespace_id: impl Into<String>, session_id: SessionId, run_id: RunId) -> Self {
54        Self {
55            namespace_id: namespace_id.into(),
56            session_id,
57            run_id,
58        }
59    }
60}
61
62/// Agent-facing session operation class.
63#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum AgentSessionOperation {
66    /// List/get session and run projections.
67    Read,
68    /// Use an independently injected search provider.
69    Search,
70    /// Create a session or start a run.
71    Create,
72    /// Update title/profile/archive state.
73    Update,
74    /// Steer or interrupt a live run.
75    Control,
76    /// Tombstone a session.
77    Delete,
78}
79
80/// Host-derived authority supplied separately from model arguments.
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82pub struct AgentSessionScope {
83    /// Authorized namespace.
84    pub namespace_id: String,
85    /// Principal/owner represented by this capability.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub owner_id: Option<String>,
88    /// Product that constructed the scope.
89    pub source_product: String,
90    /// Controlling session, used for self-target denial.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub source_session_id: Option<SessionId>,
93    /// Controlling run, used for self-target denial.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub source_run_id: Option<RunId>,
96    /// Effective operation intersection. Model input cannot alter this set.
97    #[serde(default)]
98    pub operations: BTreeSet<AgentSessionOperation>,
99    /// Optional allowlist; empty means the host's namespace/owner policy decides.
100    #[serde(default)]
101    pub allowed_session_ids: BTreeSet<SessionId>,
102    /// Whether the current session may be queried.
103    #[serde(default = "default_true")]
104    pub allow_self_query: bool,
105    /// Whether the current run/session may be controlled. Hosts should normally leave false.
106    #[serde(default)]
107    pub allow_self_control: bool,
108    /// Stable fingerprint of the intersected server/profile/caller/grant policy.
109    pub policy_fingerprint: String,
110    /// Absolute deadline for commands made through this capability.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub deadline: Option<DateTime<Utc>>,
113    /// Maximum page size after host policy intersection.
114    #[serde(default = "default_page_limit")]
115    pub max_page_size: u32,
116}
117
118const fn default_true() -> bool {
119    true
120}
121
122const fn default_page_limit() -> u32 {
123    50
124}
125
126impl AgentSessionScope {
127    /// Return whether an operation is in the effective grant.
128    #[must_use]
129    pub fn allows(&self, operation: AgentSessionOperation) -> bool {
130        self.operations.contains(&operation)
131    }
132
133    /// Return whether a session id is inside the host allowlist.
134    #[must_use]
135    pub fn allows_session(&self, session_id: &SessionId) -> bool {
136        self.allowed_session_ids.is_empty() || self.allowed_session_ids.contains(session_id)
137    }
138
139    /// Return whether a run target is the controlling run.
140    #[must_use]
141    pub fn is_self_run(&self, target: &ManagedRunTarget) -> bool {
142        self.namespace_id == target.namespace_id
143            && self.source_session_id.as_ref() == Some(&target.session_id)
144            && self.source_run_id.as_ref() == Some(&target.run_id)
145    }
146
147    /// Return whether a session target is the controlling session.
148    #[must_use]
149    pub fn is_self_session(&self, target: &ManagedSessionTarget) -> bool {
150        self.namespace_id == target.namespace_id
151            && self.source_session_id.as_ref() == Some(&target.session_id)
152    }
153}
154
155/// Explicit session deletion state/fence used by run and continuation admission.
156#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
157#[serde(tag = "state", rename_all = "snake_case")]
158pub enum SessionDeletionFence {
159    /// No deletion is in progress.
160    #[default]
161    Stable,
162    /// New runs, continuations, and child delegation are fenced.
163    Deleting {
164        /// Stable deletion intent id.
165        fence_id: String,
166        /// Revision from which deletion was acquired.
167        expected_revision: u64,
168        /// Principal requesting deletion.
169        requested_by: String,
170        /// Fence acquisition time.
171        started_at: DateTime<Utc>,
172    },
173    /// Session is tombstoned; evidence retention is an admin concern.
174    Deleted {
175        /// Stable deletion intent id.
176        fence_id: String,
177        /// Tombstone time.
178        deleted_at: DateTime<Utc>,
179    },
180}
181
182impl SessionDeletionFence {
183    /// Return whether new work and async continuations must be denied.
184    #[must_use]
185    pub const fn blocks_continuation(&self) -> bool {
186        !matches!(self, Self::Stable)
187    }
188}
189
190/// Deletion/continuation hook exposed to async supervisors without coupling both control planes.
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct SessionContinuationFence {
193    /// Composite session target.
194    pub target: ManagedSessionTarget,
195    /// Session revision at the authorization check.
196    pub revision: u64,
197    /// True when continuation/delegation is allowed.
198    pub continuation_allowed: bool,
199    /// Stable deletion fence id when blocked.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub fence_id: Option<String>,
202}
203
204/// Compact query for canonical session listing.
205#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
206pub struct AgentSessionListQuery {
207    /// Optional exact status.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub status: Option<SessionStatus>,
210    /// Optional exact profile.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub profile: Option<String>,
213    /// Optional exact workspace display value.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub workspace: Option<String>,
216    /// Maximum page size.
217    #[serde(default = "default_page_limit")]
218    pub limit: u32,
219    /// Opaque keyset token.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub page_token: Option<String>,
222}
223
224/// Sections requested by a get-session operation.
225#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
226pub struct AgentSessionInclude {
227    /// Include bounded recent run summaries.
228    #[serde(default)]
229    pub recent_runs: bool,
230    /// Include compact trace counts.
231    #[serde(default)]
232    pub trace: bool,
233}
234
235/// Compact run list query.
236#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
237pub struct AgentRunListQuery {
238    /// Maximum page size.
239    #[serde(default = "default_page_limit")]
240    pub limit: u32,
241    /// Opaque session-local keyset token.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub page_token: Option<String>,
244}
245
246impl Default for AgentRunListQuery {
247    fn default() -> Self {
248        Self {
249            limit: default_page_limit(),
250            page_token: None,
251        }
252    }
253}
254
255/// Display-safe replay query.
256#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
257pub struct AgentReplayQuery {
258    /// Family-aware cursor.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub after: Option<ReplayCursor>,
261    /// Maximum number of display messages.
262    #[serde(default = "default_page_limit")]
263    pub limit: u32,
264}
265
266/// Prompt-safe compact session projection.
267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
268pub struct AgentSessionView {
269    /// Composite target.
270    pub target: ManagedSessionTarget,
271    /// Optional bounded title.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub title: Option<String>,
274    /// Canonical status.
275    pub status: SessionStatus,
276    /// Optional profile.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub profile: Option<String>,
279    /// Safe workspace display value, never a backend locator.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub workspace: Option<String>,
282    /// Optimistic concurrency token.
283    pub revision: u64,
284    /// Head run id.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub head_run_id: Option<RunId>,
287    /// Current active run id when canonical storage has one.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub active_run_id: Option<RunId>,
290    /// Whether the current host can resume this session.
291    pub resumable: bool,
292    /// Whether the current host has a fenced local control handle.
293    pub controllable: bool,
294    /// Bounded recent run summaries.
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub recent_runs: Vec<AgentRunView>,
297    /// Creation time.
298    pub created_at: DateTime<Utc>,
299    /// Last update time.
300    pub updated_at: DateTime<Utc>,
301}
302
303/// Prompt-safe compact run projection.
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
305pub struct AgentRunView {
306    /// Composite run target.
307    pub target: ManagedRunTarget,
308    /// Durable status.
309    pub status: RunStatus,
310    /// Session-local order.
311    pub sequence_no: usize,
312    /// Bounded text input preview.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub input_preview: Option<String>,
315    /// Bounded output preview.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub output_preview: Option<String>,
318    /// Safe error category, never raw provider diagnostics.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub error_category: Option<String>,
321    /// Whether this process currently owns fenced control.
322    pub controllable: bool,
323    /// Creation time.
324    pub created_at: DateTime<Utc>,
325    /// Last update time.
326    pub updated_at: DateTime<Utc>,
327}
328
329/// Page of compact sessions.
330#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
331pub struct AgentSessionPage {
332    /// Results.
333    pub sessions: Vec<AgentSessionView>,
334    /// Opaque continuation token.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub next_page_token: Option<String>,
337}
338
339/// Page of compact runs.
340#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
341pub struct AgentRunPage {
342    /// Results.
343    pub runs: Vec<AgentRunView>,
344    /// Opaque continuation token.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub next_page_token: Option<String>,
347}
348
349/// Page of sanitized historical display evidence.
350#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
351pub struct AgentDisplayPage {
352    /// Display-safe messages. Hosts must exclude internal and diagnostic visibility.
353    pub messages: Vec<DisplayMessage>,
354    /// Cursor after the final returned message.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub next_cursor: Option<ReplayCursor>,
357    /// Explicit reminder that historical content is untrusted evidence.
358    #[serde(default = "untrusted_evidence_label")]
359    pub trust: String,
360}
361
362fn untrusted_evidence_label() -> String {
363    "untrusted_historical_evidence".to_string()
364}
365
366/// Allowlisted create-session command.
367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
368pub struct CreateManagedSession {
369    /// Optional title.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub title: Option<String>,
372    /// Optional configured profile id.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub profile: Option<String>,
375    /// Host-approved workspace reference/display value.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub workspace: Option<String>,
378    /// Approved typed metadata only.
379    #[serde(default)]
380    pub metadata: BTreeMap<String, Value>,
381    /// Stable idempotency key.
382    pub idempotency_key: String,
383}
384
385/// Typed session update patch.
386#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
387pub struct ManagedSessionPatch {
388    /// Set or clear the title. Outer `None` means unchanged.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub title: Option<Option<String>>,
391    /// Set or clear the future-run profile. Outer `None` means unchanged.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub profile: Option<Option<String>>,
394    /// Explicit archive transition.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub archived: Option<bool>,
397    /// Allowlisted metadata replacements/removals (`null` removes).
398    #[serde(default)]
399    pub metadata: BTreeMap<String, Value>,
400}
401
402/// Revision-checked session update.
403#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
404pub struct UpdateManagedSession {
405    /// Session id; namespace comes from scope.
406    pub session_id: SessionId,
407    /// Required optimistic revision.
408    pub expected_revision: u64,
409    /// Explicit patch.
410    pub patch: ManagedSessionPatch,
411    /// Stable idempotency key.
412    pub idempotency_key: String,
413}
414
415/// Revision-checked tombstone command. Evidence purge is intentionally absent.
416#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
417pub struct DeleteManagedSession {
418    /// Session id; namespace comes from scope.
419    pub session_id: SessionId,
420    /// Required optimistic revision.
421    pub expected_revision: u64,
422    /// Stable idempotency key.
423    pub idempotency_key: String,
424    /// Approval evidence supplied by the host approval layer.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub approval_receipt_id: Option<String>,
427}
428
429/// Non-blocking run start command.
430#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
431pub struct StartManagedRun {
432    /// Owning session id.
433    pub session_id: SessionId,
434    /// Canonical model input.
435    pub input: Vec<InputPart>,
436    /// Optional configured profile override.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub profile: Option<String>,
439    /// Approved environment attachment references.
440    #[serde(default)]
441    pub environment_refs: Vec<String>,
442    /// Stable idempotency key.
443    pub idempotency_key: String,
444}
445
446/// Structured steering command.
447#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
448pub struct SteerManagedRun {
449    /// Composite target.
450    pub target: ManagedRunTarget,
451    /// Stable steering id.
452    pub steering_id: String,
453    /// Bounded text baseline.
454    pub text: String,
455    /// Optional idempotency key.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub idempotency_key: Option<String>,
458}
459
460/// Cooperative interruption command.
461#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
462pub struct InterruptManagedRun {
463    /// Composite target.
464    pub target: ManagedRunTarget,
465    /// Stable control operation id.
466    pub operation_id: String,
467    /// Safe bounded reason category.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub reason_category: Option<String>,
470    /// Optional idempotency key.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub idempotency_key: Option<String>,
473}
474
475/// Session mutation result.
476#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
477pub struct SessionMutationReceipt {
478    /// Stable receipt id.
479    pub receipt_id: String,
480    /// Compact canonical projection after mutation.
481    pub session: AgentSessionView,
482    /// True when an exact idempotent result was replayed.
483    pub idempotent_replay: bool,
484}
485
486/// Accepted non-blocking run result.
487#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
488pub struct RunStartReceipt {
489    /// Stable receipt id.
490    pub receipt_id: String,
491    /// Composite target and fencing generation.
492    pub target: ManagedRunTarget,
493    /// Accepted durable status.
494    pub status: RunStatus,
495    /// Admission fencing generation.
496    pub fencing_generation: u64,
497    /// True when an exact idempotent result was replayed.
498    pub idempotent_replay: bool,
499}
500
501/// Accepted steering/interruption result.
502#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
503pub struct RunControlReceipt {
504    /// Stable durable receipt id.
505    pub receipt_id: String,
506    /// Composite target.
507    pub target: ManagedRunTarget,
508    /// Operation id or steering id.
509    pub operation_id: String,
510    /// Fencing generation against which it was accepted.
511    pub fencing_generation: u64,
512    /// Accepted means queued/signalled, not completed/consumed.
513    pub accepted: bool,
514    /// True when an exact idempotent result was replayed.
515    pub idempotent_replay: bool,
516    /// Receipt creation time.
517    pub created_at: DateTime<Utc>,
518}
519
520/// Durable one-active-run lease owned by a host instance.
521#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
522pub struct RunAdmissionLease {
523    /// Composite target.
524    pub target: ManagedRunTarget,
525    /// Stable admission intent id.
526    pub admission_id: String,
527    /// Current host instance id.
528    pub host_instance_id: String,
529    /// Monotonic fencing generation for this session.
530    pub fencing_generation: u64,
531    /// Lease expiry.
532    pub lease_expires_at: DateTime<Utc>,
533    /// Last heartbeat.
534    pub heartbeat_at: DateTime<Utc>,
535    /// Normalized start-command fingerprint.
536    pub command_fingerprint: String,
537    /// Idempotency key bound to the fingerprint.
538    pub idempotency_key: String,
539}
540
541impl starweaver_core::VersionedRecord for RunAdmissionLease {
542    const SCHEMA: &'static str = "starweaver.session.run_admission_lease";
543}
544
545impl RunAdmissionLease {
546    /// Return whether this lease is expired at `now`.
547    #[must_use]
548    pub fn expired_at(&self, now: DateTime<Utc>) -> bool {
549        self.lease_expires_at <= now
550    }
551}
552
553/// Request for atomic one-active-run admission.
554#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
555pub struct AcquireRunAdmission {
556    /// Queued/starting run record to persist atomically with the lease.
557    pub run: crate::RunRecord,
558    /// Namespace derived by the product.
559    pub namespace_id: String,
560    /// Host instance claiming ownership.
561    pub host_instance_id: String,
562    /// Stable admission id.
563    pub admission_id: String,
564    /// Lease expiry.
565    pub lease_expires_at: DateTime<Utc>,
566    /// Stable idempotency key.
567    pub idempotency_key: String,
568    /// Normalized command fingerprint.
569    pub command_fingerprint: String,
570}
571
572/// Result of admission or an exact retry.
573#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
574pub struct RunAdmissionReceipt {
575    /// Persisted run.
576    pub run: crate::RunRecord,
577    /// Durable lease/fencing evidence.
578    pub lease: RunAdmissionLease,
579    /// True for same-key/same-command retry.
580    pub idempotent_replay: bool,
581}
582
583impl starweaver_core::VersionedRecord for RunAdmissionReceipt {
584    const SCHEMA: &'static str = "starweaver.session.run_admission_receipt";
585}
586
587/// Durable receipt stored independently from a process-local control handle.
588#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
589pub struct DurableControlReceipt {
590    /// Stable receipt id.
591    pub receipt_id: String,
592    /// Composite target.
593    pub target: ManagedRunTarget,
594    /// Steering/interruption operation id.
595    pub operation_id: String,
596    /// Operation category.
597    pub operation: String,
598    /// Idempotency key.
599    pub idempotency_key: String,
600    /// Safe normalized fingerprint.
601    pub command_fingerprint: String,
602    /// Matching owner generation.
603    pub fencing_generation: u64,
604    /// Effect state (`reserved`, `accepted`, or `failed`).
605    pub state: String,
606    /// Creation time.
607    pub created_at: DateTime<Utc>,
608}
609
610impl starweaver_core::VersionedRecord for DurableControlReceipt {
611    const SCHEMA: &'static str = "starweaver.session.durable_control_receipt";
612}
613
614/// Required query error categories.
615#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
616#[serde(rename_all = "snake_case")]
617pub enum AgentSessionQueryErrorCode {
618    /// Query fields or limits are invalid.
619    InvalidQuery,
620    /// Authorized canonical target was not found (also used to hide unauthorized targets).
621    NotFound,
622    /// Optional capability is not installed.
623    Unsupported,
624    /// Canonical provider is temporarily unavailable.
625    Unavailable,
626    /// Effective host scope denies the operation.
627    PermissionDenied,
628    /// Cursor is malformed, stale, or belongs to another query/scope.
629    InvalidCursor,
630    /// Bounded internal query failure.
631    Failed,
632}
633
634/// Prompt-safe query error.
635#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
636pub struct AgentSessionQueryError {
637    /// Stable category.
638    pub code: AgentSessionQueryErrorCode,
639    /// Bounded safe message.
640    pub message: String,
641}
642
643impl std::fmt::Display for AgentSessionQueryError {
644    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645        write!(formatter, "{:?}: {}", self.code, self.message)
646    }
647}
648
649impl std::error::Error for AgentSessionQueryError {}
650
651/// Required control error categories.
652#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
653#[serde(rename_all = "snake_case")]
654pub enum AgentSessionControlErrorCode {
655    /// Command fields or limits are invalid.
656    InvalidCommand,
657    /// Authorized target was not found (also used to hide unauthorized targets).
658    NotFound,
659    /// Effective host scope denies the operation.
660    PermissionDenied,
661    /// A host approval receipt is required.
662    ApprovalRequired,
663    /// Optimistic revision or lifecycle conflict.
664    Conflict,
665    /// Idempotency key was reused for a different normalized command.
666    IdempotencyConflict,
667    /// Session already owns an active run slot.
668    RunConflict,
669    /// Target has no current process-local control handle.
670    NotActive,
671    /// Target run is immutable and terminal.
672    Terminal,
673    /// Durable state is active but no matching fenced owner exists.
674    StaleActive,
675    /// Host quota rejects the operation.
676    QuotaExceeded,
677    /// Required coordinator or storage capability is unavailable.
678    Unavailable,
679    /// Bounded internal control failure.
680    Failed,
681}
682
683/// Prompt-safe control error.
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
685pub struct AgentSessionControlError {
686    /// Stable category.
687    pub code: AgentSessionControlErrorCode,
688    /// Bounded safe message.
689    pub message: String,
690    /// Current safe session revision for CAS conflicts.
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub current_revision: Option<u64>,
693}
694
695impl std::fmt::Display for AgentSessionControlError {
696    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        write!(formatter, "{:?}: {}", self.code, self.message)
698    }
699}
700
701impl std::error::Error for AgentSessionControlError {}