feat(config): land the wave-3 per-feature family-seam handles (w3-seam-config)

- ServeConfig gains cfg-gated SessionSeam / GitSeam Option fields
  (whole-Option struct per seam — the shape decision recorded in the
  task notes): unset → the owned rows decline even though the feature
  is on, no construction-time validation
- SessionSeam carries the alktty backends map + backend selector +
  ownership provider; GitSeam carries the alkgit backend quartet +
  caller-owned pipeline semaphore + push_options_negotiated
- optional deps alktty 0.8.1 / alkgit 0.1.0 wired feature-to-dep
  (pins verified against crates.io: licenses, MSRV, shared alkcall
  resolution); gix-hash 0.28 as dev-dep only (alkgit does not
  re-export ObjectId for the trait doubles)
- per-feature construction/unset-declines/Debug rows in config.rs;
  accessors session_seam_configured / git_seam_configured; docs state
  what each seam handle carries

Verified: cargo test (per-feature + --all-features), clippy ×2
(-D warnings), fmt, cargo doc --no-deps, cargo tree feature isolation
(git-build: no alktty; session-build: no alkgit), taskgraph validate
This commit is contained in:
glm-5.3-flash committed 2026-10-28 16:12:03 +00:00
1 parent d1f755f2d9
commit 270365ea4c
5 files changed
+1351 -21

No files matched your search

+429 -5
View File
@@ -8,8 +8,12 @@
//! - [`ServeConfig`] — the server direction's assembly-supplied serving
//! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] /
//! [`ServeConfig::exec_wrapper_argv`]), the injected identity,
//! host-key and tunnel handles, the OQ-09 `cwd`/`env` values, and the
//! [`Limits`].
//! host-key and tunnel handles, the per-feature family-seam handles
//! (the wave-3 extension — [`SessionSeam`] carries the alktty
//! backends map + backend selector + ownership provider;
//! [`GitSeam`] carries the alkgit backend quartet + the caller-owned
//! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values,
//! and the [`Limits`].
//! - [`DialConfig`] — the client direction's dial configuration
//! (`client.md` §The dial surface): address, key source, host-key
//! policy, and the bounded-resource knobs (carried by [`Limits`]).
@@ -22,7 +26,14 @@
//! request time (wave-2/3 consumption), never a fallback, never
//! construction-time validation (`error.rs`'s no-config-error
//! contract). No construction-time validation exists in this
//! module.
//! module. The wave-3 seam handles extend the same rule per
//! feature: [`ServeConfig::session_seam`] unset → the session rows
//! decline even though the `session` feature is on;
//! [`ServeConfig::git_seam`] unset → the git row declines even
//! though `git` is on (the decline matrix's config-driven notes;
//! the feature alone configures nothing). The tunnel handle stays
//! the flat `Option<Arc<ChannelClient>>` as landed — a single-field
//! seam needs no struct.
//! - **Injected handles, never crate-resolved identity or trust**:
//! the [`IdentityProvider`], [`HostKeySource`] and
//! [`HostKeyPolicy`] handles are `Arc<dyn ..>` the assembly
@@ -44,12 +55,20 @@
//! budget model.
use std::collections::BTreeMap;
#[cfg(feature = "session")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use alkcall::channels::client::ChannelClient;
use alkcall::core::auth::IdentityProvider;
#[cfg(feature = "session")]
use alkcall::core::ownership::OwnershipProvider;
#[cfg(feature = "git")]
use alkgit::{GitPackGen, GitPackIngest, GitRefs, GitRegistry};
#[cfg(feature = "session")]
use alktty::TtyBackend;
use crate::host_key::HostKeyPolicy;
use crate::key_source::{HostKeySource, KeySource};
@@ -172,6 +191,101 @@ impl Default for Limits {
}
}
/// The `session` feature's alktty seam handle (`mapping.md` §The
/// seams): what `drive_session[_pre_negotiated]` takes that the
/// assembly composes (alktty 0.8.1's `adapter.rs:205-241` signature).
/// **Shape decision** (recorded at `w3-seam-config`): a whole-`Option`
/// struct per seam, not flat `Option` fields — each seam is consumed
/// atomically by exactly one seam call, so a struct admits no
/// half-supplied seam states and the unset-declines contract reads as
/// one `Option`: **`ServeConfig::session_seam` unset → the session
/// rows decline even though the `session` feature is on** (the
/// decline matrix's config-driven note), checked at request time by
/// the mappings — no construction-time validation. Cloned into each
/// spawned serving task (all payload types `Arc` or plain data).
#[cfg(feature = "session")]
#[derive(Clone)]
pub struct SessionSeam {
/// The alktty backends map — the `TtyAdapter`-style registry keyed
/// by the `NegotiateRequest.backend` selector string. **Assembled
/// by the assembly**: alkssh never touches a real backend, and the
/// `local` backend shapes stay alktty-side (this dep's `local`
/// feature is not enabled — the map holds assembly-supplied
/// implementations of alktty's [`TtyBackend`] only).
pub backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
/// The backend **selector** — the `NegotiateRequest.backend`
/// string alkssh writes into the locally-constructed negotiation
/// (`w3-tty-session`'s negotiation rows; the wire command/args
/// never reach it). Assembly-supplied, never wire-derived.
pub backend: String,
/// The ADR-050 ownership provider — passed through
/// `drive_session[_pre_negotiated]` verbatim (the family ACL
/// layer riding alongside; an alkcall type, no new dep).
pub ownership: Option<Arc<dyn OwnershipProvider>>,
}
#[cfg(feature = "session")]
impl std::fmt::Debug for SessionSeam {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut keys: Vec<&String> = self.backends.keys().collect();
keys.sort();
f.debug_struct("SessionSeam")
.field("backend_keys", &keys)
.field("backend", &self.backend)
.field("ownership_set", &self.ownership.is_some())
.finish()
}
}
/// The `git` feature's alkgit seam handle (`mapping.md` §The seams):
/// what `serve_duplex_resolved` takes that the assembly composes
/// (alkgit 0.1.0's `duplex.rs:1412-1426` signature — the four backend
/// traits, the caller-owned pipeline, the push-options gate). Same
/// whole-`Option`-struct decision as [`SessionSeam`]:
/// **`ServeConfig::git_seam` unset → the git row declines even though
/// the `git` feature is on**, checked at request time by the
/// mappings — no construction-time validation.
#[cfg(feature = "git")]
#[derive(Clone)]
pub struct GitSeam {
/// The repo-id resolution trait (`GitRegistry::resolve`) — the
/// ADR-007 pre-protocol gate's other half; alkssh never resolves
/// (the collapsed-generic-denial posture lives in alkgit).
pub registry: Arc<dyn GitRegistry>,
/// The ref listing + CAS transaction trait
/// (`list_refs`/`apply_updates`).
pub refs: Arc<dyn GitRefs>,
/// The pack generation + negotiation-ack trait
/// (`generate`/`common_haves`).
pub packgen: Arc<dyn GitPackGen>,
/// The pack-preparation trait (`prepare` — never applies; the
/// `GitRefs::apply_updates` CAS stays the single ingestion home).
pub ingest: Arc<dyn GitPackIngest>,
/// The assembler's push-options flag (alkgit's ADR-013 §11 gate,
/// passed through verbatim) — `false` in the v1 default.
pub push_options_negotiated: bool,
/// The caller-owned, server-wide pipeline permit — alkgit's
/// pack-pipeline concurrency bound, *shared across every session
/// the serving direction takes* (the caller keeps its own handle;
/// `Arc` for the share). One per `DialConfig`-independent serve
/// direction, never per channel.
pub pipeline: Arc<tokio::sync::Semaphore>,
}
#[cfg(feature = "git")]
impl std::fmt::Debug for GitSeam {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitSeam")
.field("registry", &"<dyn GitRegistry>")
.field("refs", &"<dyn GitRefs>")
.field("packgen", &"<dyn GitPackGen>")
.field("ingest", &"<dyn GitPackIngest>")
.field("push_options_negotiated", &self.push_options_negotiated)
.field("pipeline_permits", &self.pipeline.available_permits())
.finish()
}
}
/// The server direction's assembly-supplied serving configuration
/// (`overview.md` §Server stack): everything wave-2's serving loop
/// takes at construction. Pure data + injected handles — no russh
@@ -215,6 +329,22 @@ pub struct ServeConfig {
/// `NegotiateRequest.env` — **never wire-derived**. Empty by
/// default.
pub session_env: BTreeMap<String, String>,
/// The `session` feature's alktty seam handle ([`SessionSeam`] —
/// the backends map + backend selector + ownership provider the
/// `drive_session[_pre_negotiated]` rows take). **Unset → the
/// session rows decline even though the `session` feature is on**
/// (the decline matrix's config-driven note; the feature alone
/// configures nothing). Wave-3 extension of the wave-1 config
/// surface — shape decision recorded on [`SessionSeam`].
#[cfg(feature = "session")]
pub session_seam: Option<SessionSeam>,
/// The `git` feature's alkgit seam handle ([`GitSeam`] — the four
/// backend traits + the caller-owned pipeline + the push-options
/// flag the `serve_duplex_resolved` row takes). **Unset → the git
/// row declines even though the `git` feature is on** (same
/// postures as [`ServeConfig::session_seam`]).
#[cfg(feature = "git")]
pub git_seam: Option<GitSeam>,
/// The injected identity provider (alkcall's
/// `IdentityProvider::resolve_from_fingerprint`) — the assembly
/// supplies it; the crate never resolves identities itself and
@@ -255,6 +385,10 @@ impl ServeConfig {
exec_wrapper_argv: None,
session_cwd: None,
session_env: BTreeMap::new(),
#[cfg(feature = "session")]
session_seam: None,
#[cfg(feature = "git")]
git_seam: None,
identity_provider,
host_key_source,
channel_client: None,
@@ -284,17 +418,47 @@ impl ServeConfig {
pub fn tunnel_configured(&self) -> bool {
self.channel_client.is_some()
}
/// Whether the session rows' family seam is configured in this
/// configuration ([`SessionSeam`] present; the `session` feature
/// gate applies at the dispatch on top): `false` means the rows
/// decline — the unset-declines contract, checked at request time
/// by the wave-3 mappings, never here.
#[cfg(feature = "session")]
pub fn session_seam_configured(&self) -> bool {
self.session_seam.is_some()
}
/// Whether the git row's family seam is configured in this
/// configuration ([`GitSeam`] present; the `git` feature gate
/// applies at the dispatch on top): `false` means the row declines
/// — the unset-declines contract, checked at request time by the
/// wave-3 mappings, never here.
#[cfg(feature = "git")]
pub fn git_seam_configured(&self) -> bool {
self.git_seam.is_some()
}
}
impl std::fmt::Debug for ServeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServeConfig")
let mut debug = f.debug_struct("ServeConfig");
debug
.field("shell_argv", &self.shell_argv)
.field("exec_wrapper_argv", &self.exec_wrapper_argv)
.field("session_cwd", &self.session_cwd)
.field("session_env", &self.session_env)
.field("identity_provider", &"<dyn IdentityProvider>")
.field("host_key_source", &"<dyn HostKeySource>")
.field("host_key_source", &"<dyn HostKeySource>");
#[cfg(feature = "session")]
{
debug.field("session_seam", &self.session_seam.is_some());
}
#[cfg(feature = "git")]
{
debug.field("git_seam", &self.git_seam.is_some());
}
debug
.field("channel_client", &self.channel_client.as_ref().is_some())
.field("limits", &self.limits)
.finish()
@@ -503,6 +667,169 @@ mod tests {
assert_eq!(serve.session_env.len(), 1);
}
#[cfg(feature = "git")]
mod git_seam {
use alkgit::{
GitPackGen, GitPackIngest, GitRefs, GitRegistry, PreparedPush, PushOptions, RefLine,
RefOutcome, RefUpdate, RegistryError, RepoRecord, StorageError,
};
use async_trait::async_trait;
use gix_hash::ObjectId;
use super::super::{GitSeam, ServeConfig};
use super::*;
struct NoopRegistry;
#[async_trait]
impl GitRegistry for NoopRegistry {
async fn resolve(&self, _repo_id: &str) -> Result<RepoRecord, RegistryError> {
Err(RegistryError::NotFound)
}
}
struct NoopRefs;
#[async_trait]
impl GitRefs for NoopRefs {
async fn list_refs(&self, _repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
Ok(Vec::new())
}
async fn apply_updates(
&self,
_repo: &RepoRecord,
_updates: Vec<RefUpdate>,
_atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
Ok(Vec::new())
}
}
struct NoopPackGen;
#[async_trait]
impl GitPackGen for NoopPackGen {
async fn generate(
&self,
_repo: &RepoRecord,
_wants: Vec<ObjectId>,
_boundary_haves: Vec<ObjectId>,
_limits: alkgit::Limits,
_sink: Box<dyn std::io::Write + Send>,
) -> Result<(), StorageError> {
Ok(())
}
async fn common_haves(
&self,
_repo: &RepoRecord,
_haves: Vec<ObjectId>,
) -> Result<Vec<ObjectId>, StorageError> {
Ok(Vec::new())
}
}
struct NoopPackIngest;
#[async_trait]
impl GitPackIngest for NoopPackIngest {
async fn prepare(
&self,
_repo: &RepoRecord,
_updates: Vec<RefUpdate>,
_pack: Box<dyn std::io::Read + Send>,
_push_options: Option<PushOptions>,
_limits: alkgit::Limits,
) -> Result<PreparedPush, StorageError> {
Ok(PreparedPush {
unpack: Ok(()),
updates: Vec::new(),
})
}
}
fn assembled_seam() -> GitSeam {
GitSeam {
registry: Arc::new(NoopRegistry),
refs: Arc::new(NoopRefs),
packgen: Arc::new(NoopPackGen),
ingest: Arc::new(NoopPackIngest),
push_options_negotiated: false,
pipeline: Arc::new(tokio::sync::Semaphore::new(2)),
}
}
#[test]
fn serve_config_git_seam_unset_declines_though_the_feature_is_on() {
let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
assert!(serve.git_seam.is_none());
assert!(!serve.git_seam_configured());
let debug = format!("{serve:?}");
assert!(debug.contains("git_seam: false"));
}
#[test]
fn serve_config_holds_the_git_seam_whole_and_cloneable() {
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
let seam = assembled_seam();
let registry = Arc::clone(&seam.registry);
serve.git_seam = Some(seam);
assert!(serve.git_seam_configured());
let held = serve.git_seam.as_ref().expect("the seam holds");
assert!(Arc::ptr_eq(&registry, &held.registry));
assert!(!held.push_options_negotiated);
assert_eq!(held.pipeline.available_permits(), 2);
let cloned = held.clone();
assert!(Arc::ptr_eq(&cloned.registry, &registry));
assert!(!cloned.push_options_negotiated);
assert_eq!(cloned.pipeline.available_permits(), 2);
let debug = format!("{serve:?}");
assert!(debug.contains("git_seam: true"));
}
#[test]
fn git_seam_pipeline_is_the_single_server_wide_share() {
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
let pipeline = Arc::new(tokio::sync::Semaphore::new(2));
let mut seam = assembled_seam();
seam.pipeline = Arc::clone(&pipeline);
serve.git_seam = Some(seam);
let held = serve.git_seam.as_ref().expect("the seam holds");
let cloned = held.clone();
assert!(Arc::ptr_eq(&pipeline, &held.pipeline));
assert!(Arc::ptr_eq(&pipeline, &cloned.pipeline));
let _permit = cloned
.pipeline
.try_acquire()
.expect("a permit is available");
assert_eq!(held.pipeline.available_permits(), 1);
}
#[test]
fn git_seam_debug_shapes_itself_not_the_backends() {
let seam = assembled_seam();
let debug = format!("{seam:?}");
assert!(debug.contains("GitSeam"));
assert!(debug.contains("<dyn GitRegistry>"));
assert!(debug.contains("<dyn GitRefs>"));
assert!(debug.contains("<dyn GitPackGen>"));
assert!(debug.contains("<dyn GitPackIngest>"));
assert!(debug.contains("push_options_negotiated: false"));
assert!(debug.contains("pipeline_permits: 2"));
}
}
#[test]
fn dial_config_defaults_carry_the_keepalive_posture() {
let dial = DialConfig::new("runner.example:22", key_source(), Arc::new(RejectAll));
@@ -528,6 +855,103 @@ mod tests {
assert!(Arc::ptr_eq(&policy, &dial.host_key_policy));
}
// The wave-3 per-feature seam handles: the construction shapes, the
// unset-declines-preserved rows and the Debug posture — each block
// only runs in the build that carries its feature (compile-time
// feature isolation: no git build ever touches alktty types,
// no session build ever touches alkgit types).
#[cfg(feature = "session")]
mod session_seam {
use alktty::{TtyBackend, TtyError, TtyHandle, TtyParams};
use async_trait::async_trait;
use super::super::{ServeConfig, SessionSeam};
use super::*;
struct StubBackend;
#[async_trait]
impl TtyBackend for StubBackend {
async fn allocate(&self, _params: &TtyParams) -> Result<TtyHandle, TtyError> {
Err(TtyError::AllocFailed {
message: "stub".into(),
})
}
}
fn assembled_seam() -> SessionSeam {
let mut backends = std::collections::HashMap::new();
backends.insert("stub".to_string(), Arc::new(StubBackend) as _);
SessionSeam {
backends: Arc::new(backends),
backend: "stub".to_string(),
ownership: None,
}
}
#[test]
fn serve_config_session_seam_unset_declines_though_the_feature_is_on() {
let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
assert!(serve.session_seam.is_none());
assert!(!serve.session_seam_configured());
let debug = format!("{serve:?}");
assert!(debug.contains("session_seam: false"));
}
#[test]
fn serve_config_holds_the_session_seam_whole_and_cloneable() {
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
let seam = assembled_seam();
let backends = Arc::clone(&seam.backends);
serve.session_seam = Some(seam);
assert!(serve.session_seam_configured());
let held = serve.session_seam.as_ref().expect("the seam holds");
assert!(Arc::ptr_eq(&backends, &held.backends));
assert_eq!(held.backend, "stub");
assert!(held.ownership.is_none());
let cloned = held.clone();
assert!(Arc::ptr_eq(&cloned.backends, &backends));
assert_eq!(cloned.backend, "stub");
let debug = format!("{serve:?}");
assert!(debug.contains("session_seam: true"));
}
#[test]
fn session_seam_configure_is_one_option_independent_of_the_oq08_fields() {
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
serve.session_seam = Some(assembled_seam());
assert!(serve.session_seam_configured());
assert!(!serve.shell_configured());
assert!(!serve.exec_wrapper_configured());
serve.shell_argv = Some(vec!["/bin/sh".to_string(), "-l".to_string()]);
assert!(serve.shell_configured());
assert!(serve.session_seam_configured());
serve.session_seam = None;
assert!(serve.shell_configured());
assert!(!serve.session_seam_configured());
}
#[test]
fn session_seam_debug_shapes_itself_not_the_backends() {
let seam = assembled_seam();
let debug = format!("{seam:?}");
assert!(debug.contains("SessionSeam"));
assert!(debug.contains(r#"backend_keys: ["stub"]"#));
assert!(debug.contains(r#"backend: "stub""#));
assert!(debug.contains("ownership_set: false"));
}
}
#[tokio::test]
async fn dial_config_key_source_stays_fail_closed_through_the_config() {
let dial = DialConfig::new("fleet.example:22", key_source(), Arc::new(RejectAll));
+4
View File
@@ -36,6 +36,10 @@ mod key_source;
mod serve;
pub use adapter::SshAdapter;
#[cfg(feature = "git")]
pub use config::GitSeam;
#[cfg(feature = "session")]
pub use config::SessionSeam;
pub use config::{DialConfig, Limits, ServeConfig};
pub use error::{AdapterError, KeySourceError};
pub use fingerprint::fingerprint;