Hexagonal Architecture¶
Volundr uses hexagonal architecture (ports and adapters) to keep business logic independent of infrastructure. The domain layer defines abstract interfaces (ports). Infrastructure code implements them (adapters). The composition root wires everything together at startup.
Directory Structure¶
src/volundr/
├── domain/
│ ├── models.py # Domain models (Session, Chronicle, User, Tenant, etc.)
│ ├── ports.py # All port interfaces (abstract base classes)
│ └── services/
│ ├── session.py # Session lifecycle orchestration
│ ├── chronicle.py # Chronicle management, reforge, timeline
│ ├── git_workflow.py # PR creation, merge, CI status
│ ├── tenant.py # Tenant hierarchy, default tenant
│ ├── token.py # Token usage recording and cost
│ ├── stats.py # Aggregate dashboard statistics
│ ├── repo.py # Repository listing across providers
│ ├── credential.py # Credential store operations
│ ├── preset.py # User-created runtime config presets
│ ├── prompt.py # Saved prompts
│ ├── tracker.py # Issue tracker operations
│ ├── tracker_factory.py # Per-user issue tracker instantiation
│ ├── event_ingestion.py # Multi-sink event dispatch
│ ├── integration_registry.py # Integration type catalog
│ ├── user_integration.py # Per-user provider factory
│ ├── mcp_injection.py # MCP server configuration injection
│ ├── mount_strategies.py # Secret mount type strategies
│ ├── secret_mount.py # Secret mount spec resolution
│ ├── profile.py # Profile listing with session counts
│ ├── template.py # Workspace template operations
│ └── workspace.py # Workspace PVC management
├── adapters/
│ ├── inbound/ # REST API routes (FastAPI routers)
│ │ ├── rest.py # Sessions, chronicles, timeline, stats, SSE
│ │ ├── rest_git.py # Git workflow endpoints
│ │ ├── rest_profiles.py # Profiles and templates
│ │ ├── rest_presets.py # Presets
│ │ ├── rest_prompts.py # Saved prompts
│ │ ├── rest_tenants.py # Tenants and users
│ │ ├── rest_credentials.py # Credential management
│ │ ├── rest_secrets.py # MCP servers, K8s secrets
│ │ ├── rest_events.py # Event pipeline
│ │ ├── rest_integrations.py # Integration connections
│ │ ├── rest_tracker.py # Issue tracker
│ │ ├── rest_resources.py # Cluster resources
│ │ ├── rest_admin_settings.py # Admin settings
│ │ └── auth.py # Auth dependency injection
│ └── outbound/ # Infrastructure adapters
│ ├── contributors/ # Session contributor pipeline
│ │ ├── core.py # Session identity, ingress, terminal
│ │ ├── template.py # Workspace template resolution
│ │ ├── git.py # Git clone URL and credentials
│ │ ├── integrations.py # MCP servers and env from integrations
│ │ ├── storage.py # PVC provisioning
│ │ ├── gateway.py # Gateway API HTTPRoute config
│ │ ├── resource.py # CPU/memory/GPU translation
│ │ ├── isolation.py # Namespace, security context
│ │ ├── secrets.py # K8s secret env refs
│ │ └── local_mount.py # Host path mounts (local dev)
│ ├── postgres.py # PostgresSessionRepository
│ ├── postgres_chronicles.py
│ ├── postgres_timeline.py
│ ├── postgres_tokens.py
│ ├── postgres_stats.py
│ ├── postgres_presets.py
│ ├── postgres_prompts.py
│ ├── postgres_tenants.py
│ ├── postgres_users.py
│ ├── pg_event_sink.py # PostgreSQL event sink
│ ├── rabbitmq_event_sink.py
│ ├── otel_event_sink.py
│ ├── broadcaster.py # In-memory SSE broadcaster
│ ├── github.py # GitHub GitProvider + GitWorkflowProvider
│ ├── gitlab.py # GitLab GitProvider + GitWorkflowProvider
│ ├── git_registry.py # Multi-provider git registry
│ ├── flux.py # Flux-based PodManager (HelmRelease)
│ ├── direct_k8s_pod_manager.py # Direct K8s PodManager
│ ├── k8s_storage.py # K8s PVC StoragePort
│ ├── identity.py # OIDC IdentityPort adapters
│ ├── authorization.py # AuthorizationPort (Cerbos, allow-all)
│ ├── vault_credential_store.py
│ ├── infisical_credential_store.py
│ ├── file_credential_store.py
│ ├── memory_credential_store.py
│ ├── infisical_secret_injection.py
│ ├── memory_secret_injection.py
│ ├── static_resource_provider.py
│ ├── config_profiles.py # YAML-driven ProfileProvider
│ ├── config_templates.py # YAML-driven TemplateProvider
│ ├── config_mcp_servers.py
│ ├── pricing.py # Hardcoded model pricing
│ ├── memory_secrets.py
│ ├── memory_secret_repo.py
│ ├── memory_integrations.py
│ ├── jira.py # Jira IssueTrackerProvider
│ └── linear.py # Linear IssueTrackerProvider
├── skuld/ # Skuld broker (separate FastAPI app)
│ ├── broker.py # WebSocket broker, REST endpoints
│ ├── transport.py # CLI transport abstractions
│ ├── channels.py # Output channel registry
│ ├── service_manager.py # Multi-session management
│ └── config.py # Skuld-specific settings
├── config.py # Configuration classes (Settings, all sub-configs)
├── main.py # Composition root (wires everything)
├── utils.py # import_class() and shared utilities
└── infrastructure/
└── database.py # asyncpg pool management, schema creation
Layer Rules¶
Three rules, no exceptions:
-
Domain imports nothing from adapters.
domain/models.py,domain/ports.py, and all files underdomain/services/never import fromadapters/. The domain layer defines the interfaces; it does not know what implements them. -
Adapters implement ports and import domain models. An adapter like
postgres.pyimportsSessionRepository(the port it implements) andSession(the domain model it persists). It never imports from other adapters. -
main.pyis the composition root. It imports from everywhere -- domain services, ports, and adapters -- and wires them together. This is the only place where concrete adapter classes are referenced by the application startup code.
All Ports¶
Every port is an abstract base class defined in domain/ports.py. Here is the complete list:
| Port | Methods | Purpose |
|---|---|---|
SessionRepository |
create, get, list, update, delete | Session persistence |
ChronicleRepository |
create, get, get_by_session, list, update, delete, get_chain | Chronicle persistence and reforge chain traversal |
TimelineRepository |
add_event, get_events, get_events_by_session, delete_by_chronicle | Timeline event storage |
PodManager |
start, stop, status, wait_for_ready | Session pod lifecycle |
StatsRepository |
get_stats | Aggregate dashboard statistics |
TokenTracker |
record_usage, get_session_usage | Token usage recording |
PricingProvider |
get_price, list_models | Model pricing and metadata |
GitProvider |
provider_type, name, orgs, supports, validate_repo, parse_repo, get_clone_url, list_repos, list_branches | Git repository operations for a single provider endpoint |
GitWorkflowProvider |
create_branch, create_pull_request, get_pull_request, list_pull_requests, merge_pull_request, get_ci_status | PR and CI workflow operations |
EventBroadcaster |
publish, subscribe | SSE event fan-out |
EventSink |
emit, emit_batch, flush, close, sink_name, healthy | Event pipeline sink |
SessionEventRepository |
get_events, get_event_counts, get_token_timeline, delete_by_session | Event query port |
ProfileProvider |
get, list, get_default | Read-only forge profiles |
MutableProfileProvider |
(extends ProfileProvider) create, update, delete | Writable profiles |
TemplateProvider |
get, list | Read-only workspace templates |
SavedPromptRepository |
create, get, list, update, delete, search | Saved prompts |
PresetRepository |
create, get, get_by_name, list, update, delete, clear_default | Runtime presets |
MCPServerProvider |
list, get | MCP server configs |
SecretManager |
list, get, create | K8s secret management |
IssueTrackerProvider |
provider_name, check_connection, search_issues, get_recent_issues, get_issue, update_issue_status | External issue tracker |
IntegrationRepository |
list_connections, get_connection, save_connection, delete_connection | Integration connection storage |
ProjectMappingRepository |
create, list, get_by_repo, delete | Repo-to-tracker project mappings |
TenantRepository |
create, get, get_by_path, list, get_ancestors, update, delete | Tenant hierarchy |
UserRepository |
create, get, get_by_email, list, update, delete, add_membership, get_memberships, get_members, remove_membership | Users and tenant membership |
IdentityPort |
validate_token, get_or_provision_user | JWT validation and JIT provisioning |
AuthorizationPort |
is_allowed, filter_allowed | Action-level authorization |
SecretRepository |
store_credential, get_credential, delete_credential, list_credentials, provision_user, deprovision_user, create_session_secrets, delete_session_secrets | Vault/OpenBao operations |
StoragePort |
provision_user_storage, create_session_workspace, archive_session_workspace, delete_workspace, get_user_storage_usage, deprovision_user_storage, list_workspaces, list_all_workspaces, get_workspace_by_session | PVC lifecycle |
GatewayPort |
get_gateway_config | Gateway API routing config |
CredentialStorePort |
store, get, get_value, delete, list, health_check | Pluggable credential storage |
SecretMountStrategy |
secret_type, default_mount_spec, validate | Per-type secret mount logic |
SecretInjectionPort |
pod_spec_additions, provision_user, deprovision_user | CSI driver pod spec generation |
ResourceProvider |
discover, translate, validate | Cluster resource discovery and translation |
SessionContributor |
name, contribute, cleanup | Contributor pipeline element |
Dynamic Adapter Loading¶
Adapters are loaded at runtime from fully-qualified class paths specified in YAML config. This is the import_class() mechanism:
# src/volundr/utils.py
def import_class(dotted_path: str) -> type:
module_path, class_name = dotted_path.rsplit(".", 1)
module = importlib.import_module(module_path)
return getattr(module, class_name)
Configuration specifies the adapter class and any kwargs:
pod_manager:
adapter: "volundr.adapters.outbound.flux.FluxPodManager"
kwargs:
namespace: "volundr-sessions"
chart_name: "skuld"
identity:
adapter: "volundr.adapters.outbound.identity.OIDCIdentityAdapter"
kwargs:
issuer_url: "https://keycloak.example.com/realms/volundr"
audience: "volundr-api"
secret_kwargs_env:
client_secret: "OIDC_CLIENT_SECRET"
The composition root (main.py) uses this pattern for every infrastructure adapter:
def _create_pod_manager(settings: Settings) -> PodManager:
pm_cfg = settings.pod_manager
cls = import_class(pm_cfg.adapter)
kwargs = _resolve_secret_kwargs(pm_cfg.kwargs, pm_cfg.secret_kwargs_env)
instance = cls(**kwargs)
return instance
secret_kwargs_env maps kwarg names to environment variable names. This lets sensitive values (API keys, client secrets) come from the environment rather than config files.
Contributor Wiring¶
Contributors are slightly different. They receive both config kwargs and injected port instances, because they need to call other ports (storage, git, gateway, etc.):
def _create_contributors(settings: Settings, **ports: object) -> list[SessionContributor]:
contributors = []
for cfg in settings.session_contributors:
cls = import_class(cfg.adapter)
resolved_kwargs = _resolve_secret_kwargs(cfg.kwargs, cfg.secret_kwargs_env)
# Merge config kwargs with injected ports
kwargs = {**resolved_kwargs, **ports}
instance = cls(**kwargs)
contributors.append(instance)
return contributors
Each contributor constructor accepts the ports it needs by name and ignores the rest via **_extra:
class StorageContributor(SessionContributor):
def __init__(
self,
*,
storage: StoragePort,
admin_settings: dict,
**_extra: object, # Ignore ports this contributor doesn't need
):
self._storage = storage
self._admin_settings = admin_settings
This means adding a new contributor is:
- Write the class, accepting its ports by name.
- Add the class path to the
session_contributorslist in YAML config. - If the contributor needs a new port, pass it in
_create_contributors().
No match/case chains. No if/else adapter selection. The config declares what to load; import_class() loads it.
Adding a New Adapter¶
To swap out an infrastructure backend (say, replacing Vault with a new secret store):
- Write a new class that implements the port (
CredentialStorePort). - Put it anywhere in the
adapters/outbound/directory. - Update the YAML config to point to the new class:
credential_store:
adapter: "volundr.adapters.outbound.my_new_store.MyNewCredentialStore"
kwargs:
endpoint: "https://newsecrets.example.com"
No code changes to main.py, no changes to the domain layer, no changes to any other adapter.