Skip to main content

supercode_harness/
sdk.rs

1//! Versioned public SDK contract shared by every Supercode surface.
2//!
3//! This module names the operations, capabilities, events, and errors that
4//! transports project. CLI, JSON-RPC, HTTP, MCP, ACP, and language clients
5//! may add correlation ids or wire metadata, but they must not define a
6//! second execution contract or place those envelope fields in a session.
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::path::Path;
12use std::sync::Arc;
13
14use crate::{
15    Agent, Config, DiscoveryPage, DiscoveryQuery, Fidelity, HarnessCatalog, Result as CoreResult,
16    Session, SessionDescriptor, SessionLocator,
17};
18
19/// Renderable prompt source configured on an SDK emulation component.
20///
21/// MCP implements this seam, but the SDK does not depend on MCP transport or
22/// client types, so removing the MCP adapter leaves runtime semantics intact.
23#[async_trait]
24pub trait SdkPromptSource: Send + Sync {
25    /// Render one prompt with its named arguments.
26    async fn render(&self, args: std::collections::BTreeMap<String, String>) -> CoreResult<String>;
27    /// Declared argument names in stable source order.
28    fn arg_names(&self) -> &[String];
29}
30
31/// Current language-neutral SDK schema.
32pub const SDK_SCHEMA_VERSION: &str = "supercode.sdk.v1";
33
34/// Discover persisted sessions through the canonical SDK catalog owner.
35pub fn discover_sessions(query: &DiscoveryQuery) -> CoreResult<Vec<SessionDescriptor>> {
36    Ok(HarnessCatalog::new().discover(query)?)
37}
38
39/// Discover one persisted-session page with its opaque successor cursor.
40pub fn discover_session_page(query: &DiscoveryQuery) -> CoreResult<DiscoveryPage> {
41    Ok(HarnessCatalog::new().discover_page(query)?)
42}
43
44/// Load one durable locator through the canonical SDK catalog owner.
45pub fn load_session(locator: &SessionLocator) -> CoreResult<Session> {
46    Ok(HarnessCatalog::new().load(locator)?)
47}
48
49/// [`load_session`] at a declared fidelity.
50///
51/// Read-only surfaces pass [`Fidelity::Semantic`] so a transcript whose record
52/// graph cannot be reconstructed exactly still renders, with the degradation
53/// named in [`Session::load_residue`]. Continuation, transfer and export
54/// callers keep the strict default of [`load_session`].
55pub fn load_session_with_fidelity(
56    locator: &SessionLocator,
57    fidelity: Fidelity,
58) -> CoreResult<Session> {
59    Ok(HarnessCatalog::new().load_with_fidelity(locator, fidelity)?)
60}
61
62/// Load an explicit transcript/store path through the SDK import boundary.
63/// An OpenCode selector is accepted only for its SQLite store.
64pub fn load_session_path(path: &Path, opencode_session: Option<&str>) -> CoreResult<Session> {
65    if opencode_session.is_some() {
66        return Ok(Session::from_opencode_sqlite(path, opencode_session)?);
67    }
68    if let Some(session) = load_native_store_family(path)? {
69        return Ok(session);
70    }
71    Ok(Session::load(path)?)
72}
73
74pub(crate) fn load_native_store_family(path: &Path) -> CoreResult<Option<Session>> {
75    Ok(supercode_interchange::load_native_store_family(path)?)
76}
77
78/// SDK-owned emulation runtime component.
79///
80/// The wrapper makes ownership transfer explicit: public adapters receive an
81/// SDK component, and [`crate::server::RpcEngine`] consumes that component as
82/// the sole live-loop owner. It intentionally does not implement `Deref`:
83/// model/tool-loop entry points stay unreachable outside the SDK/runtime
84/// implementation boundary.
85pub struct SdkAgent(Agent);
86
87impl SdkAgent {
88    pub(crate) fn from_agent(agent: Agent) -> Self {
89        Self(agent)
90    }
91
92    pub(crate) fn inner(&self) -> &Agent {
93        &self.0
94    }
95
96    pub(crate) fn inner_mut(&mut self) -> &mut Agent {
97        &mut self.0
98    }
99
100    /// Read the resolved runtime configuration without acquiring loop ownership.
101    pub fn config(&self) -> &Config {
102        self.0.config()
103    }
104
105    /// Install the full-fidelity sidecar writer used by SDK persistence.
106    pub fn set_recorder(&mut self, writer: crate::sidecar::SidecarWriter) {
107        self.0.set_recorder(writer);
108    }
109
110    // ---- BP-8: session durability (catalog:150/151/152/154/156) ---------
111
112    /// Install the append-only session journal — see
113    /// [`crate::agent::Agent::set_journal`].
114    pub fn set_journal(&mut self, journal: crate::session_journal::SessionJournal) {
115        self.0.set_journal(journal);
116    }
117
118    /// Whether an append-only journal is installed.
119    pub fn has_journal(&self) -> bool {
120        self.0.has_journal()
121    }
122
123    /// Declare the durable view caught up at `messages` messages.
124    pub fn journal_checkpoint(&self, messages: usize) {
125        self.0.journal_checkpoint(messages);
126    }
127
128    /// This session's in-place conversation tree, when the module is on.
129    pub fn session_tree(&self) -> Option<&crate::session_tree::SessionTree> {
130        self.0.session_tree()
131    }
132
133    /// Install a tree loaded from the store.
134    pub fn set_session_tree(&mut self, tree: crate::session_tree::SessionTree) {
135        self.0.set_session_tree(tree);
136    }
137
138    /// Materialize the degenerate single-path tree from the live history.
139    pub fn rebuild_session_tree_from_history(&mut self) {
140        self.0.rebuild_session_tree_from_history();
141    }
142
143    /// Rewind THIS conversation to an earlier point, recorded and
144    /// invertible — see [`crate::agent::Agent::rewind_conversation`].
145    pub fn rewind_conversation(&mut self, keep: usize) -> crate::agent::RewindOutcome {
146        self.0.rewind_conversation(keep)
147    }
148
149    /// Invert the most recent rewind.
150    pub fn undo_rewind(&mut self) -> bool {
151        self.0.undo_rewind()
152    }
153
154    /// How many rewinds are currently undoable.
155    pub fn undoable_rewinds(&self) -> usize {
156        self.0.undoable_rewinds()
157    }
158
159    /// Restore an undo stack recovered from the journal.
160    pub fn restore_rewind_undo(&mut self, stack: Vec<Vec<crate::ChatMessage>>) {
161        self.0.restore_rewind_undo(stack);
162    }
163
164    /// Re-queue pending inputs recovered from the journal.
165    pub fn restore_queues(&mut self, steer: &[String], follow_up: &[String]) {
166        self.0.restore_queues(steer, follow_up);
167    }
168
169    /// Append messages recovered from the journal after a crash.
170    pub fn append_recovered_messages(&mut self, messages: &[crate::ChatMessage]) {
171        self.0.append_recovered_messages(messages);
172    }
173
174    /// The session's `update_plan` checklist.
175    pub fn plan(&self) -> Vec<crate::session_journal::PlanEntry> {
176        self.0.plan()
177    }
178
179    /// Restore a plan read back from the store.
180    pub fn set_plan(&mut self, steps: Vec<crate::session_journal::PlanEntry>) {
181        self.0.set_plan(steps);
182    }
183
184    /// BP-8: arm `[core.session]`'s durability for `name` and restore what a
185    /// previous process left — see [`crate::session_journal::arm`].
186    pub fn arm_session_journal(
187        &mut self,
188        store: &crate::store::SessionStore,
189        name: &str,
190    ) -> crate::session_journal::RestoreReport {
191        crate::session_journal::arm(&mut self.0, store, name)
192    }
193
194    /// BP-8: the mirror — see [`crate::session_journal::checkpoint`].
195    pub fn checkpoint_session_journal(
196        &self,
197        store: &crate::store::SessionStore,
198        name: &str,
199        messages: usize,
200    ) {
201        crate::session_journal::checkpoint(&self.0, store, name, messages);
202    }
203
204    /// BP-3 (§2 module 8 `plan_mode`): the session's shared plan-mode state
205    /// — what a composer's `/plan` toggles and the permission gate reads.
206    /// Reading or toggling it acquires no loop ownership.
207    pub fn plan_mode(&self) -> &std::sync::Arc<crate::tools::PlanModeState> {
208        self.0.plan_mode()
209    }
210
211    /// Install the reversible provider-view reduction policy.
212    pub fn set_reduction_policy(&mut self, policy: crate::reduce::ReductionPolicy) {
213        self.0.set_reduction_policy(policy);
214    }
215
216    /// Inspect the current provider-view reduction policy.
217    pub fn reduction_policy(&self) -> Option<&crate::reduce::ReductionPolicy> {
218        self.0.reduction_policy()
219    }
220
221    /// Replace the reversible reduction log after an SDK-owned projection.
222    pub fn set_reduction_log(&mut self, log: crate::reduce::ReductionLog) {
223        self.0.set_reduction_log(log);
224    }
225
226    /// Inspect the reversible reduction log.
227    pub fn reduction_log(&self) -> &crate::reduce::ReductionLog {
228        self.0.reduction_log()
229    }
230
231    /// Prepare optional cleared-turn summary metadata without sending a turn.
232    pub fn prepare_cleared_turns_summary(
233        &self,
234        messages: &[crate::ChatMessage],
235        policy: &crate::reduce::ReductionPolicy,
236        prior: &crate::reduce::ReductionLog,
237    ) -> Option<crate::reduce::PreparedClearSummary> {
238        self.0
239            .prepare_cleared_turns_summary(messages, policy, prior)
240    }
241
242    /// Install a reduction span summarizer.
243    pub fn set_span_summarizer(
244        &mut self,
245        summarizer: impl crate::reduce::summarize::SpanSummarizer + Send + Sync + 'static,
246    ) {
247        self.0.set_span_summarizer(summarizer);
248    }
249
250    /// Install the optional persisted-session title generator.
251    pub fn set_session_titler(
252        &mut self,
253        titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
254    ) {
255        self.0.set_session_titler(titler);
256    }
257
258    /// Generate a title from canonical history when configured.
259    pub fn auto_title(&self) -> Option<String> {
260        self.0.auto_title()
261    }
262
263    /// Attach a session store for SDK-owned subagent persistence.
264    pub fn set_subagent_store(
265        &mut self,
266        store: std::sync::Arc<crate::SessionStore>,
267        session_name: impl Into<String>,
268    ) {
269        self.0.set_subagent_store(store, session_name);
270    }
271
272    /// Install restored Claude runtime state without activating a timer.
273    pub fn set_claude_runtime_manifest(
274        &mut self,
275        manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
276    ) {
277        self.0.set_claude_runtime_manifest(manifest);
278    }
279
280    /// Inspect restored Claude runtime state.
281    pub fn claude_runtime_manifest(
282        &self,
283    ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
284        self.0.claude_runtime_manifest()
285    }
286
287    /// Mutate Claude runtime state from the SDK scheduler/persistence driver.
288    pub fn claude_runtime_manifest_mut(
289        &mut self,
290    ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
291        self.0.claude_runtime_manifest_mut()
292    }
293
294    /// Restore project-scoped Claude agent definitions after disk reload.
295    pub fn restore_claude_project_agents(&mut self) -> CoreResult<usize> {
296        self.0.restore_claude_project_agents()
297    }
298
299    /// Replace canonical history with a loaded normalized session.
300    pub fn load_session(&mut self, session: Session) {
301        self.0.load_session(session);
302    }
303
304    /// Load a Supercode transcript through the SDK component.
305    pub fn load_transcript(&mut self, path: impl AsRef<Path>) -> CoreResult<()> {
306        self.0.load_transcript(path)
307    }
308
309    /// Save the canonical transcript through the SDK component.
310    pub fn save_transcript(&self, path: impl AsRef<Path>) -> CoreResult<()> {
311        self.0.save_transcript(path)
312    }
313
314    /// Read canonical history at a quiescent boundary.
315    pub fn history(&self) -> &[crate::ChatMessage] {
316        self.0.history()
317    }
318
319    /// Rewind canonical history to a prior message boundary.
320    pub fn rewind_to(&mut self, checkpoint: usize) {
321        self.0.rewind_to(checkpoint);
322    }
323
324    /// BP-4 (catalog:98): compact now, with optional `/compact <focus>`
325    /// steering text — see [`Agent::compact_now`].
326    pub fn compact_now(&mut self, focus: Option<&str>) -> bool {
327        self.0.compact_now(focus)
328    }
329
330    /// BP-4 (catalog:109): live context-window accounting — see
331    /// [`Agent::context_usage`].
332    pub fn context_usage(&self) -> crate::ContextUsage {
333        self.0.context_usage()
334    }
335
336    /// BP-4 (catalog:106): reset the working view to a fresh objective plus
337    /// a curated keep-set — see [`Agent::new_context`].
338    pub fn new_context(&mut self, objective: &str, keep_recent: Option<usize>) -> usize {
339        self.0.new_context(objective, keep_recent)
340    }
341
342    /// BP-4 (catalog:91): splice an ambient context block into the live
343    /// session — see [`Agent::inject_context_block`].
344    pub fn inject_context_block(
345        &mut self,
346        name: impl Into<String>,
347        content: impl Into<String>,
348    ) -> bool {
349        self.0.inject_context_block(name, content)
350    }
351
352    /// BP-4 (catalog:90): re-derive and re-emit the environment context
353    /// block if it changed — see [`Agent::refresh_env_context`].
354    pub fn refresh_env_context(&mut self) -> bool {
355        self.0.refresh_env_context()
356    }
357
358    /// Append an SDK-assembled system note.
359    pub fn append_system_note(&mut self, text: &str) {
360        self.0.append_system_note(text);
361    }
362
363    /// Register one configured tool before transferring live-loop ownership.
364    pub fn register_tool(&mut self, tool: impl crate::Tool + 'static) {
365        self.0.register_tool(tool);
366    }
367
368    /// Register one MCP prompt source before transferring loop ownership.
369    pub fn register_mcp_prompt(
370        &mut self,
371        command_name: impl Into<String>,
372        source: impl SdkPromptSource + 'static,
373    ) {
374        self.0.register_mcp_prompt(command_name, source);
375    }
376
377    /// Inspect the exact next-request tool schemas for preflight measurement.
378    pub fn tool_schemas(&self) -> Vec<crate::ToolSchema> {
379        self.0.tool_schemas()
380    }
381
382    /// Arm the per-request context limit guard.
383    pub fn set_context_limit(&mut self, limit: u64) {
384        self.0.set_context_limit(limit);
385    }
386
387    /// Inspect the armed context limit.
388    pub fn context_limit(&self) -> Option<u64> {
389        self.0.context_limit()
390    }
391
392    /// Switch the next-request model at a quiescent boundary.
393    pub fn set_model(&mut self, model: impl Into<String>) {
394        self.0.set_model(model);
395    }
396
397    /// BP-13 (catalog D9 "Fast mode / service tiers"): set or clear the
398    /// session-level service-tier override — what `/fast` pulls. `None`
399    /// restores whatever the routing table resolved for the model.
400    pub fn set_service_tier(&mut self, tier: Option<String>) {
401        self.0.set_service_tier(tier);
402    }
403
404    /// BP-13: the model this runtime sends on its next request.
405    pub fn model(&self) -> &str {
406        self.0.model()
407    }
408
409    /// BP-13 (catalog D9 "Mid-session model switching"): the GOVERNED
410    /// switch — under `[core.model_switch] allow_switch` it strips model-A
411    /// reasoning artifacts from the live history (dep 8), records the
412    /// change in the session journal, and emits it, before moving
413    /// `Config::model`. With the gate off it is exactly [`Self::set_model`].
414    pub fn switch_model(&mut self, model: impl Into<String>) {
415        self.0.switch_model(model);
416    }
417
418    /// Whether a provider request has actually been issued.
419    pub fn request_issued(&self) -> bool {
420        self.0.request_issued()
421    }
422
423    /// Configured durable session name.
424    pub fn session_name(&self) -> Option<&str> {
425        self.0.session_name()
426    }
427
428    /// Whether persistence is enabled for this component.
429    pub fn session_persist(&self) -> bool {
430        self.0.session_persist()
431    }
432
433    /// Captured git provenance.
434    pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
435        self.0.git_metadata()
436    }
437
438    /// Save captured git provenance.
439    pub fn save_git_metadata(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
440        self.0.save_git_metadata(store, name)
441    }
442
443    /// BP-7: save the accumulated per-turn usage log (tokens + cost).
444    pub fn save_usage_log(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
445        self.0.save_usage_log(store, name)
446    }
447
448    /// BP-7: the per-round-trip marker log.
449    pub fn turn_records(&self) -> &[crate::turn_record::TurnRecord] {
450        self.0.turn_records()
451    }
452
453    /// BP-7: save the per-round-trip marker log.
454    pub fn save_turn_records(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
455        self.0.save_turn_records(store, name)
456    }
457
458    /// BP-7: record that the in-flight turn was interrupted.
459    pub fn note_abort(&mut self, source: &str) {
460        self.0.note_abort(source);
461    }
462
463    /// BP-7: dollars spent so far (see [`Self::model_priced`]).
464    pub fn total_cost_usd(&self) -> f64 {
465        self.0.total_cost_usd()
466    }
467
468    /// BP-7: whether this build can price this component's model.
469    pub fn model_priced(&self) -> bool {
470        self.0.model_priced()
471    }
472
473    /// BP-7: tool calls executed so far.
474    pub fn total_steps(&self) -> usize {
475        self.0.total_steps()
476    }
477
478    /// BP-7: set or revise the session's standing objective.
479    pub fn set_goal(&mut self, objective: impl Into<String>) -> bool {
480        self.0.set_goal(objective)
481    }
482
483    /// BP-7: the session's standing objective.
484    pub fn goal(&self) -> Option<&crate::goals::GoalRecord> {
485        self.0.goal()
486    }
487
488    /// BP-7: drop the standing objective.
489    pub fn clear_goal(&mut self) -> bool {
490        self.0.clear_goal()
491    }
492
493    /// BP-7: adopt a goal loaded from the store.
494    pub fn restore_goal(&mut self, goal: Option<crate::goals::GoalRecord>) {
495        self.0.restore_goal(goal);
496    }
497
498    /// BP-7: persist (or clear) the standing objective.
499    pub fn save_goal(&self, store: &crate::SessionStore, name: &str) -> CoreResult<()> {
500        self.0.save_goal(store, name)
501    }
502
503    /// BP-7: the reasoning-effort level in force, `None` when off.
504    pub fn effort(&self) -> Option<&str> {
505        self.0.effort()
506    }
507
508    /// BP-7: change reasoning effort mid-session; `None` turns it off.
509    pub fn set_effort(&mut self, effort: Option<String>) -> Option<String> {
510        self.0.set_effort(effort)
511    }
512
513    /// BP-7: this harness's review-turn prompt with `{args}` filled in.
514    pub fn review_prompt(&self, args: &str) -> Option<String> {
515        self.0.review_prompt(args)
516    }
517
518    /// BP-7: a tool-less question over the full context that records
519    /// nothing.
520    pub async fn side_question(&self, question: &str) -> CoreResult<String> {
521        self.0.side_question(question).await
522    }
523
524    /// Number of non-system canonical messages.
525    pub fn turn_count(&self) -> usize {
526        self.0.turn_count()
527    }
528
529    /// Cumulative provider-reported output tokens.
530    pub fn total_output_tokens(&self) -> u64 {
531        self.0.total_output_tokens()
532    }
533}
534
535impl From<Agent> for SdkAgent {
536    fn from(agent: Agent) -> Self {
537        Self::from_agent(agent)
538    }
539}
540
541/// Construct the emulation component inside the SDK ownership boundary.
542pub fn create_agent(config: Config) -> CoreResult<SdkAgent> {
543    Agent::new(config).map(SdkAgent::from_agent)
544}
545
546/// Resume canonical history inside a fresh SDK-owned emulation component.
547pub fn resume_agent(config: Config, session: Session) -> CoreResult<SdkAgent> {
548    Agent::resume(config, session).map(SdkAgent::from_agent)
549}
550
551/// Submit one text turn through the SDK-owned emulation loop.
552pub async fn submit_agent(agent: &mut SdkAgent, prompt: &str) -> CoreResult<String> {
553    agent.0.send(prompt).await
554}
555
556/// BP-5 (catalog D2 "Prompt-input debugging"): render the exact model-visible
557/// input `prompt` would produce, as JSON, without sending anything.
558///
559/// The prompt is expanded exactly as [`submit_agent`] would expand it and the
560/// request is assembled by the loop's own `chat_request`, so this is the real
561/// input rather than a second rendering of it. Nothing is recorded, nothing is
562/// persisted, and no provider request is issued.
563pub async fn show_model_input(agent: &mut SdkAgent, prompt: &str) -> serde_json::Value {
564    let req = agent.0.model_input_for(prompt).await;
565    Agent::render_model_input(&req)
566}
567
568/// Submit one multimodal turn through the SDK-owned emulation loop.
569pub async fn submit_agent_with_images(
570    agent: &mut SdkAgent,
571    prompt: &str,
572    image_urls: &[String],
573) -> CoreResult<String> {
574    agent.0.send_with_images(prompt, image_urls).await
575}
576
577/// One operation owned by the SDK facade.
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
579#[serde(rename_all = "snake_case")]
580pub enum SdkOperation {
581    /// Discover persisted sessions.
582    Discover,
583    /// Load one persisted session without modifying it.
584    Load,
585    /// Start a harness-native runtime.
586    Start,
587    /// Resume a harness-native persisted runtime.
588    Resume,
589    /// Send input to an SDK-owned runtime connection.
590    Input,
591    /// Poll canonical runtime events.
592    Events,
593    /// Interrupt the active turn.
594    Interrupt,
595    /// Queue guidance at the next model-loop boundary.
596    Steer,
597    /// Answer a typed runtime request.
598    Respond,
599    /// Export a loaded session through a native serializer.
600    Export,
601    /// List the harness's scheduled jobs (observed tier, read-only).
602    JobsList,
603    /// Read one scheduled job's definition (observed tier, read-only).
604    JobsGet,
605    /// Create a scheduled job through the harness's own verb (ORCH-18).
606    JobsCreate,
607    /// Patch a scheduled job through the harness's own verb.
608    JobsUpdate,
609    /// Stop the harness's scheduler from firing a job.
610    JobsPause,
611    /// Let the harness's scheduler fire a job again.
612    JobsResume,
613    /// Fire a job now through the harness's own verb.
614    JobsRun,
615    /// Delete a scheduled job through the harness's own verb.
616    JobsDelete,
617    /// Reconcile a declared job set through the harness's own verbs.
618    JobsApply,
619    /// Read a job's notepad from the harness's own store.
620    JobsNotepad,
621    /// Write one notepad key through the harness's own verb.
622    JobsNotepadSet,
623    /// Remove one notepad key through the harness's own verb.
624    JobsNotepadDelete,
625    /// Declare where model calls go, through the harness's own config verb.
626    ModelRouteApply,
627    /// Open a fresh conversation through the harness's own door (ORCH-19).
628    SessionsNew,
629    /// Reset a conversation through the harness's own door.
630    SessionsReset,
631    /// Archive a conversation through the harness's own door.
632    SessionsArchive,
633    /// Delete a conversation through the harness's own door.
634    SessionsDelete,
635    /// List a job's past fires with their outcomes (ORCH-8, read-only).
636    RunsList,
637    /// Read one fire's outcome (ORCH-8, read-only).
638    RunsGet,
639    /// Close an SDK-owned runtime connection.
640    Close,
641    /// List the harnesses' named config homes (ORCH-10, read-only).
642    ProfilesList,
643    /// Read one named config home by harness and name.
644    ProfilesGet,
645    /// Create a named config home through the harness's own verb (ORCH-21).
646    ProfilesCreate,
647    /// Delete a named config home through the harness's own verb (ORCH-21).
648    ProfilesDelete,
649    /// List the skill packages each harness has installed (ORCH-11, read-only).
650    SkillsList,
651    /// Install a skill package through the harness's own door (ORCH-22).
652    SkillsInstall,
653    /// Remove a skill package through the harness's own door (ORCH-22).
654    SkillsRemove,
655    /// Read a harness's persistent memory documents (ORCH-12, read-only).
656    MemoryShow,
657    /// Search those same documents by substring or regex (ORCH-12, read-only).
658    MemorySearch,
659    /// List the approval requests waiting for an answer (ORCH-9, read-only).
660    ApprovalsList,
661    /// Answer one listed approval request with a uniform decision (ORCH-20).
662    ApprovalsResolve,
663    /// List the transports each gateway harness is reachable on (ORCH-14).
664    ChannelsList,
665    /// List the routes a gateway harness uses to pick a profile / agent (ORCH-15).
666    RoutesList,
667    /// List a gateway harness's inbound triggers — webhook routes and hook mappings (ORCH-16).
668    TriggersList,
669    /// Read one channel's row by harness and name (ORCH-14).
670    ChannelsStatus,
671    /// Read one home folder as one typed orchestration value (ONT-4).
672    OrchestrationLoad,
673    /// Write an orchestration value back into our own folder (ONT-4).
674    OrchestrationSave,
675    /// Compile another harness's home into an orchestration value (ONT-4).
676    OrchestrationCompile,
677    /// Decompile an orchestration back into another harness's home (ONT-4).
678    OrchestrationDecompile,
679    /// Another harness's home becomes our folder, credentials along (ONT-7).
680    OrchestrationImport,
681    /// Our folder becomes another harness's home, credentials along (ONT-7).
682    OrchestrationExport,
683    /// Read a harness's board as one typed workflow value.
684    WorkflowLoad,
685}
686
687impl SdkOperation {
688    /// Complete v1 operation inventory in stable declaration order.
689    pub const ALL: [Self; 52] = [
690        Self::Discover,
691        Self::Load,
692        Self::Start,
693        Self::Resume,
694        Self::Input,
695        Self::Events,
696        Self::Interrupt,
697        Self::Steer,
698        Self::Respond,
699        Self::Export,
700        Self::JobsList,
701        Self::JobsGet,
702        Self::JobsCreate,
703        Self::JobsUpdate,
704        Self::JobsPause,
705        Self::JobsResume,
706        Self::JobsRun,
707        Self::JobsDelete,
708        Self::JobsApply,
709        Self::JobsNotepad,
710        Self::JobsNotepadSet,
711        Self::JobsNotepadDelete,
712        Self::ModelRouteApply,
713        Self::SessionsNew,
714        Self::SessionsReset,
715        Self::SessionsArchive,
716        Self::SessionsDelete,
717        Self::RunsList,
718        Self::RunsGet,
719        Self::Close,
720        Self::ProfilesList,
721        Self::ProfilesGet,
722        Self::ProfilesCreate,
723        Self::ProfilesDelete,
724        Self::SkillsList,
725        Self::SkillsInstall,
726        Self::SkillsRemove,
727        Self::MemoryShow,
728        Self::MemorySearch,
729        Self::ApprovalsList,
730        Self::ApprovalsResolve,
731        Self::ChannelsList,
732        Self::RoutesList,
733        Self::TriggersList,
734        Self::ChannelsStatus,
735        Self::OrchestrationLoad,
736        Self::OrchestrationSave,
737        Self::OrchestrationCompile,
738        Self::OrchestrationDecompile,
739        Self::OrchestrationImport,
740        Self::OrchestrationExport,
741        Self::WorkflowLoad,
742    ];
743
744    /// Canonical `harness.v1` method used by JSON transports, when the
745    /// operation is request/response rather than a subscription poll.
746    pub const fn method(self) -> Option<&'static str> {
747        match self {
748            Self::Discover => Some("harness.v1.sessions.discover"),
749            Self::Load => Some("harness.v1.sessions.load"),
750            Self::Start => Some("harness.v1.runtimes.start"),
751            Self::Resume => Some("harness.v1.runtimes.resume"),
752            Self::Input => Some("harness.v1.runtimes.send_input"),
753            Self::Events => None,
754            Self::Interrupt => Some("harness.v1.runtimes.interrupt"),
755            Self::Steer => Some("harness.v1.runtimes.steer"),
756            Self::Respond => Some("harness.v1.runtimes.respond"),
757            Self::Export => Some("harness.v1.sessions.export"),
758            Self::JobsList => Some("harness.v1.jobs.list"),
759            Self::JobsGet => Some("harness.v1.jobs.get"),
760            Self::JobsCreate => Some("harness.v1.jobs.create"),
761            Self::JobsUpdate => Some("harness.v1.jobs.update"),
762            Self::JobsPause => Some("harness.v1.jobs.pause"),
763            Self::JobsResume => Some("harness.v1.jobs.resume"),
764            Self::JobsRun => Some("harness.v1.jobs.run"),
765            Self::JobsDelete => Some("harness.v1.jobs.delete"),
766            Self::JobsApply => Some("harness.v1.jobs.apply"),
767            Self::JobsNotepad => Some("harness.v1.jobs.notepad"),
768            Self::JobsNotepadSet => Some("harness.v1.jobs.notepad_set"),
769            Self::JobsNotepadDelete => Some("harness.v1.jobs.notepad_delete"),
770            Self::ModelRouteApply => Some("harness.v1.model_route.apply"),
771            Self::SessionsNew => Some("harness.v1.sessions.new"),
772            Self::SessionsReset => Some("harness.v1.sessions.reset"),
773            Self::SessionsArchive => Some("harness.v1.sessions.archive"),
774            Self::SessionsDelete => Some("harness.v1.sessions.delete"),
775            Self::RunsList => Some("harness.v1.runs.list"),
776            Self::RunsGet => Some("harness.v1.runs.get"),
777            Self::Close => Some("harness.v1.runtimes.close"),
778            Self::ProfilesList => Some("harness.v1.profiles.list"),
779            Self::ProfilesGet => Some("harness.v1.profiles.get"),
780            Self::ProfilesCreate => Some("harness.v1.profiles.create"),
781            Self::ProfilesDelete => Some("harness.v1.profiles.delete"),
782            Self::SkillsList => Some("harness.v1.skills.list"),
783            Self::SkillsInstall => Some("harness.v1.skills.install"),
784            Self::SkillsRemove => Some("harness.v1.skills.remove"),
785            Self::MemoryShow => Some("harness.v1.memory.show"),
786            Self::MemorySearch => Some("harness.v1.memory.search"),
787            Self::ApprovalsList => Some("harness.v1.approvals.list"),
788            Self::ApprovalsResolve => Some("harness.v1.approvals.resolve"),
789            Self::ChannelsList => Some("harness.v1.channels.list"),
790            Self::RoutesList => Some("harness.v1.routes.list"),
791            Self::TriggersList => Some("harness.v1.triggers.list"),
792            Self::ChannelsStatus => Some("harness.v1.channels.status"),
793            Self::OrchestrationLoad => Some("harness.v1.orchestration.load"),
794            Self::OrchestrationSave => Some("harness.v1.orchestration.save"),
795            Self::OrchestrationCompile => Some("harness.v1.orchestration.compile"),
796            Self::OrchestrationDecompile => Some("harness.v1.orchestration.decompile"),
797            Self::OrchestrationImport => Some("harness.v1.orchestration.import"),
798            Self::OrchestrationExport => Some("harness.v1.orchestration.export"),
799            Self::WorkflowLoad => Some("harness.v1.workflow.load"),
800        }
801    }
802
803    /// Resolve one canonical method without accepting transport aliases.
804    pub fn from_method(method: &str) -> Option<Self> {
805        Self::ALL
806            .into_iter()
807            .find(|operation| operation.method() == Some(method))
808    }
809
810    /// Stable action spelling used by capability and error projections.
811    pub const fn action_name(self) -> &'static str {
812        match self {
813            Self::Discover => "discover",
814            Self::Load => "load",
815            Self::Start => "start",
816            Self::Resume => "resume",
817            Self::Input => "input",
818            Self::Events => "events",
819            Self::Interrupt => "interrupt",
820            Self::Steer => "steer",
821            Self::Respond => "respond",
822            Self::Export => "export",
823            Self::JobsList => "jobs_list",
824            Self::JobsGet => "jobs_get",
825            Self::JobsCreate => "jobs_create",
826            Self::JobsUpdate => "jobs_update",
827            Self::JobsPause => "jobs_pause",
828            Self::JobsResume => "jobs_resume",
829            Self::JobsRun => "jobs_run",
830            Self::JobsDelete => "jobs_delete",
831            Self::JobsApply => "jobs_apply",
832            Self::JobsNotepad => "jobs_notepad",
833            Self::JobsNotepadSet => "jobs_notepad_set",
834            Self::JobsNotepadDelete => "jobs_notepad_delete",
835            Self::ModelRouteApply => "model_route_apply",
836            Self::SessionsNew => "sessions_new",
837            Self::SessionsReset => "sessions_reset",
838            Self::SessionsArchive => "sessions_archive",
839            Self::SessionsDelete => "sessions_delete",
840            Self::RunsList => "runs_list",
841            Self::RunsGet => "runs_get",
842            Self::Close => "close",
843            Self::ProfilesList => "profiles_list",
844            Self::ProfilesGet => "profiles_get",
845            Self::ProfilesCreate => "profiles_create",
846            Self::ProfilesDelete => "profiles_delete",
847            Self::SkillsList => "skills_list",
848            Self::SkillsInstall => "skills_install",
849            Self::SkillsRemove => "skills_remove",
850            Self::MemoryShow => "memory_show",
851            Self::MemorySearch => "memory_search",
852            Self::ApprovalsList => "approvals_list",
853            Self::ApprovalsResolve => "approvals_resolve",
854            Self::ChannelsList => "channels_list",
855            Self::RoutesList => "routes_list",
856            Self::TriggersList => "triggers_list",
857            Self::ChannelsStatus => "channels_status",
858            Self::OrchestrationLoad => "orchestration_load",
859            Self::OrchestrationSave => "orchestration_save",
860            Self::OrchestrationCompile => "orchestration_compile",
861            Self::OrchestrationDecompile => "orchestration_decompile",
862            Self::OrchestrationImport => "orchestration_import",
863            Self::OrchestrationExport => "orchestration_export",
864            Self::WorkflowLoad => "workflow_load",
865        }
866    }
867
868    /// Resolve a stable action spelling.
869    pub fn from_action_name(action: &str) -> Option<Self> {
870        Self::ALL
871            .into_iter()
872            .find(|operation| operation.action_name() == action)
873    }
874}
875
876/// One typed SDK request before a transport adds its envelope.
877#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
878pub struct SdkRequest {
879    /// Requested SDK operation.
880    pub operation: SdkOperation,
881    /// Operation-specific language-neutral parameters.
882    #[serde(default)]
883    pub params: Value,
884}
885
886/// Stable machine-readable SDK failure categories.
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum SdkErrorCode {
890    /// No authenticated client context was supplied.
891    Unauthenticated,
892    /// The authenticated client lacks a required capability.
893    Unauthorized,
894    /// Another client owns control or no controller lease was claimed.
895    ControllerRequired,
896    /// The caller's controller lease expired before the mutation.
897    LeaseExpired,
898    /// Input did not satisfy the operation contract.
899    InvalidArgument,
900    /// The requested session, runtime, or request was not found.
901    NotFound,
902    /// A turn already owns the runtime.
903    Busy,
904    /// The selected adapter honestly does not implement the operation.
905    UnsupportedAction,
906    /// A runtime or provider operation failed.
907    Execution,
908    /// The transport closed or returned an invalid envelope.
909    Transport,
910}
911
912/// Typed turn failure shared by local, HTTP, ACP, CLI, and language adapters.
913#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
914pub enum RuntimeSubmitError {
915    /// Another turn already owns the runtime.
916    #[error("a turn is already in progress")]
917    Busy,
918    /// The active turn was cancelled through the SDK runtime handle.
919    #[error("turn interrupted")]
920    Interrupted,
921    /// The model/provider/tool loop failed.
922    #[error("{0}")]
923    Agent(String),
924}
925
926/// Typed failure returned by every SDK adapter and compatibility projection.
927#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
928pub enum SdkError {
929    /// A runtime operation was attempted without authenticated client state.
930    #[error("SDK runtime authentication required")]
931    Unauthenticated,
932    /// The authenticated client lacks a required runtime permission.
933    #[error("SDK runtime permission `{permission}` is required")]
934    Unauthorized {
935        /// Stable permission spelling.
936        permission: String,
937    },
938    /// Mutation requires the controller lease. When another client owns it,
939    /// its opaque identity and deadline are included for deterministic retry.
940    #[error("controller lease required")]
941    ControllerRequired {
942        /// Current controller, when known.
943        holder: Option<String>,
944        /// Current controller deadline, when known.
945        expires_at_ms: Option<u64>,
946    },
947    /// This client previously controlled the runtime but its lease expired.
948    #[error("controller lease expired")]
949    LeaseExpired,
950    /// Input did not satisfy an operation contract.
951    #[error("invalid SDK argument for {operation:?}: {message}")]
952    InvalidArgument {
953        /// Operation being decoded.
954        operation: SdkOperation,
955        /// Validation detail.
956        message: String,
957    },
958    /// A stable identity was not found.
959    #[error("SDK target for {operation:?} was not found: {message}")]
960    NotFound {
961        /// Operation being executed.
962        operation: SdkOperation,
963        /// Lookup detail.
964        message: String,
965    },
966    /// The active adapter does not implement the requested action.
967    #[error("SDK action `{0}` is not supported by this runtime")]
968    UnsupportedAction(&'static str),
969    /// The requested catalog operation is absent or has no typed route.
970    #[error("SDK operation `{0}` is not supported by this runtime")]
971    UnsupportedOperation(String),
972    /// A slow consumer fell behind the bounded live-event channel.
973    #[error("SDK event stream lost {0} event(s); reattach for a fresh snapshot")]
974    ReplayGap(u64),
975    /// The runtime closed its event stream.
976    #[error("SDK runtime event stream closed")]
977    Closed,
978    /// An authenticated remote transport failed or returned an invalid value.
979    #[error("SDK transport failed: {0}")]
980    Transport(String),
981    /// No live request exists for the supplied response id.
982    #[error("SDK request {0} is not pending")]
983    UnknownRequest(u64),
984    /// The response kind or value does not match the pending request.
985    #[error("invalid SDK response: {0}")]
986    InvalidResponse(String),
987    /// The canonical runtime rejected or failed a turn.
988    #[error(transparent)]
989    Submit(#[from] RuntimeSubmitError),
990    /// A session/runtime implementation failed after validation.
991    #[error("SDK execution failed for {operation:?}: {message}")]
992    Execution {
993        /// Operation being executed.
994        operation: SdkOperation,
995        /// Implementation detail.
996        message: String,
997    },
998}
999
1000impl SdkError {
1001    /// Construct a typed failure for an SDK operation.
1002    pub fn new(code: SdkErrorCode, operation: SdkOperation, message: impl Into<String>) -> Self {
1003        let message = message.into();
1004        match code {
1005            SdkErrorCode::Unauthenticated => Self::Unauthenticated,
1006            SdkErrorCode::Unauthorized => Self::Unauthorized {
1007                permission: message,
1008            },
1009            SdkErrorCode::ControllerRequired => Self::ControllerRequired {
1010                holder: None,
1011                expires_at_ms: None,
1012            },
1013            SdkErrorCode::LeaseExpired => Self::LeaseExpired,
1014            SdkErrorCode::InvalidArgument => Self::InvalidArgument { operation, message },
1015            SdkErrorCode::NotFound => Self::NotFound { operation, message },
1016            SdkErrorCode::Busy => Self::Submit(RuntimeSubmitError::Busy),
1017            SdkErrorCode::UnsupportedAction => Self::unsupported(operation),
1018            SdkErrorCode::Execution => Self::Execution { operation, message },
1019            SdkErrorCode::Transport => Self::Transport(message),
1020        }
1021    }
1022
1023    /// Construct a named unsupported-action failure.
1024    pub fn unsupported(operation: SdkOperation) -> Self {
1025        Self::UnsupportedAction(operation.action_name())
1026    }
1027
1028    /// Stable machine-readable category.
1029    pub fn code(&self) -> SdkErrorCode {
1030        match self {
1031            Self::Unauthenticated => SdkErrorCode::Unauthenticated,
1032            Self::Unauthorized { .. } => SdkErrorCode::Unauthorized,
1033            Self::ControllerRequired { .. } => SdkErrorCode::ControllerRequired,
1034            Self::LeaseExpired => SdkErrorCode::LeaseExpired,
1035            Self::InvalidArgument { .. } | Self::InvalidResponse(_) => {
1036                SdkErrorCode::InvalidArgument
1037            }
1038            Self::NotFound { .. } | Self::UnknownRequest(_) => SdkErrorCode::NotFound,
1039            Self::Submit(RuntimeSubmitError::Busy) => SdkErrorCode::Busy,
1040            Self::UnsupportedAction(_) | Self::UnsupportedOperation(_) => {
1041                SdkErrorCode::UnsupportedAction
1042            }
1043            Self::Transport(_) | Self::ReplayGap(_) | Self::Closed => SdkErrorCode::Transport,
1044            Self::Submit(_) | Self::Execution { .. } => SdkErrorCode::Execution,
1045        }
1046    }
1047
1048    /// Operation associated with this failure when it is unambiguous.
1049    pub fn operation(&self) -> Option<SdkOperation> {
1050        match self {
1051            Self::InvalidArgument { operation, .. }
1052            | Self::NotFound { operation, .. }
1053            | Self::Execution { operation, .. } => Some(*operation),
1054            Self::UnsupportedAction(action) => SdkOperation::from_action_name(action),
1055            Self::Unauthenticated
1056            | Self::Unauthorized { .. }
1057            | Self::ControllerRequired { .. }
1058            | Self::LeaseExpired => None,
1059            Self::UnknownRequest(_) | Self::InvalidResponse(_) => Some(SdkOperation::Respond),
1060            Self::Submit(_) => Some(SdkOperation::Input),
1061            Self::UnsupportedOperation(_)
1062            | Self::ReplayGap(_)
1063            | Self::Closed
1064            | Self::Transport(_) => None,
1065        }
1066    }
1067}
1068
1069/// Capability inventory for the complete v1 SDK, independent of transport.
1070#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1071pub struct SdkCapabilities {
1072    /// Schema identifier governing this descriptor.
1073    pub schema_version: String,
1074    /// Operations understood by the facade. A concrete runtime may still
1075    /// return `unsupported_action` for a mechanically unavailable action.
1076    pub operations: Vec<SdkOperation>,
1077    /// Stable error categories clients must preserve by name.
1078    pub error_codes: Vec<SdkErrorCode>,
1079    /// Whether events preserve unknown native payloads losslessly.
1080    pub opaque_events: bool,
1081}
1082
1083impl Default for SdkCapabilities {
1084    fn default() -> Self {
1085        Self {
1086            schema_version: SDK_SCHEMA_VERSION.into(),
1087            operations: SdkOperation::ALL.to_vec(),
1088            error_codes: vec![
1089                SdkErrorCode::Unauthenticated,
1090                SdkErrorCode::Unauthorized,
1091                SdkErrorCode::ControllerRequired,
1092                SdkErrorCode::LeaseExpired,
1093                SdkErrorCode::InvalidArgument,
1094                SdkErrorCode::NotFound,
1095                SdkErrorCode::Busy,
1096                SdkErrorCode::UnsupportedAction,
1097                SdkErrorCode::Execution,
1098                SdkErrorCode::Transport,
1099            ],
1100            opaque_events: true,
1101        }
1102    }
1103}
1104
1105/// Canonical event before a wire transport adds subscription metadata.
1106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1107pub struct SdkEvent {
1108    /// Monotonic sequence scoped to the SDK runtime.
1109    pub sequence: u64,
1110    /// Normalized or native event kind.
1111    pub kind: String,
1112    /// Complete payload, including unknown fields.
1113    pub payload: Value,
1114}
1115
1116impl SdkEvent {
1117    pub(crate) fn new(sequence: u64, payload: Value) -> Self {
1118        let kind = payload
1119            .get("type")
1120            .or_else(|| payload.get("method"))
1121            .and_then(Value::as_str)
1122            .unwrap_or("unknown")
1123            .to_string();
1124        Self {
1125            sequence,
1126            kind,
1127            payload,
1128        }
1129    }
1130}
1131
1132/// One runtime event paired with its durable SDK identity.
1133///
1134/// A transport may add a connection or subscription id around this value,
1135/// but those routing fields never become part of [`SdkEvent`] or a session.
1136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1137pub struct SdkRuntimeEvent {
1138    /// Stable SDK session identity, never a transport-local connection id.
1139    pub session_id: String,
1140    /// Canonical event shared by local and remote runtime adapters.
1141    pub event: SdkEvent,
1142}
1143
1144/// Canonical live-runtime contract owned by the SDK.
1145///
1146/// Frontend modules are projections of this trait. They may render events or
1147/// add transport envelopes, but they do not own a second model loop.
1148#[async_trait]
1149pub trait SdkRuntime: Send + Sync {
1150    /// Describe runtime identity, modules, commands, actions, and state.
1151    async fn describe(&self) -> Result<crate::frontend::FrontendRuntimeDescriptor, SdkError>;
1152    /// Atomically attach at the canonical history/live-event boundary.
1153    async fn attach(
1154        &self,
1155        history_limit: usize,
1156    ) -> Result<crate::frontend::FrontendAttachment, SdkError>;
1157    /// Atomically accept a new user turn and return once ownership is claimed.
1158    ///
1159    /// Exactly one simultaneous caller succeeds. The accepted turn continues
1160    /// on the SDK-owned runtime and publishes its result through the canonical
1161    /// event stream; a competing caller receives [`SdkErrorCode::Busy`]
1162    /// synchronously from this operation.
1163    async fn send_input(self: Arc<Self>, prompt: String) -> Result<(), SdkError>;
1164    /// Atomically accept a multimodal user turn and return once ownership is
1165    /// claimed. Implementations must preserve images natively or reject the
1166    /// action; silently folding them into text is never allowed.
1167    async fn send_input_with_images(
1168        self: Arc<Self>,
1169        prompt: String,
1170        image_urls: Vec<String>,
1171    ) -> Result<(), SdkError> {
1172        if image_urls.is_empty() {
1173            self.send_input(prompt).await
1174        } else {
1175            Err(SdkError::UnsupportedAction("send_input_attachments"))
1176        }
1177    }
1178    /// Submit a new user turn.
1179    async fn submit(&self, prompt: String) -> Result<String, SdkError>;
1180    /// Submit a new user turn with canonical multimodal image inputs.
1181    ///
1182    /// Frontends must pass only runtime-resolved URLs or data URIs here; the
1183    /// SDK runtime, not a remote display client, owns input interpretation.
1184    async fn submit_with_images(
1185        &self,
1186        prompt: String,
1187        image_urls: Vec<String>,
1188    ) -> Result<String, SdkError> {
1189        if image_urls.is_empty() {
1190            self.submit(prompt).await
1191        } else {
1192            Err(SdkError::UnsupportedAction("submit_attachments"))
1193        }
1194    }
1195    /// Interrupt an active turn.
1196    async fn interrupt(&self) -> Result<bool, SdkError>;
1197    /// Queue a steering instruction when supported.
1198    async fn steer(&self, prompt: String) -> Result<(), SdkError>;
1199    /// Answer a typed runtime request when supported.
1200    async fn respond(&self, response: crate::frontend::FrontendResponse) -> Result<(), SdkError>;
1201    /// Invoke one operation from the descriptor's explicit catalog.
1202    async fn invoke(
1203        &self,
1204        operation: crate::frontend::FrontendOperationInvocation,
1205    ) -> Result<crate::frontend::FrontendOperationResult, SdkError> {
1206        Err(SdkError::UnsupportedOperation(
1207            operation.operation_id().to_string(),
1208        ))
1209    }
1210    /// Read the one-controller/many-observer ownership state.
1211    async fn lease_snapshot(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1212        Err(SdkError::UnsupportedOperation("runtime.lease".into()))
1213    }
1214    /// Explicitly acquire the controller lease from another interactive
1215    /// client. Ordinary mutations never perform an implicit takeover.
1216    async fn take_control(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1217        Err(SdkError::UnsupportedOperation(
1218            "runtime.take_control".into(),
1219        ))
1220    }
1221    /// Refresh observer activity and a controller lease owned by this client.
1222    async fn heartbeat(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1223        Err(SdkError::UnsupportedOperation("runtime.heartbeat".into()))
1224    }
1225    /// Release this client's observer/controller state without stopping the
1226    /// runtime.
1227    async fn detach(&self) -> Result<crate::RuntimeLeaseSnapshot, SdkError> {
1228        Err(SdkError::UnsupportedOperation("runtime.detach".into()))
1229    }
1230    /// Explicitly close the SDK-owned runtime when the negotiated descriptor
1231    /// grants that owner-level action. Dropping an attachment is always a
1232    /// detach and never calls this operation implicitly.
1233    async fn close(&self) -> Result<(), SdkError> {
1234        Err(SdkError::unsupported(SdkOperation::Close))
1235    }
1236}
1237
1238/// Stateful SDK facade consumed by public transport adapters.
1239#[async_trait]
1240pub trait SdkService: Send {
1241    /// Describe the versioned contract without invoking a runtime.
1242    fn capabilities(&self) -> SdkCapabilities {
1243        SdkCapabilities::default()
1244    }
1245
1246    /// Execute one typed request. Transport correlation fields are not part
1247    /// of this API and therefore cannot contaminate canonical state.
1248    async fn execute(&mut self, request: SdkRequest) -> Result<Value, SdkError>;
1249
1250    /// Poll canonical runtime events without a transport envelope.
1251    async fn events(&mut self) -> Result<Vec<SdkRuntimeEvent>, SdkError>;
1252}