31 KiB
Refactoring Plan
Purpose
This document defines the refactoring track required to improve maintainability, safety, and production-readiness in the current codebase. It is intentionally implementation-oriented: each area is split into concrete slices, target modules, function ownership, migration order, and acceptance criteria.
The refactoring scope covers:
- splitting
crates/crank-registry/src/postgres.rsinto domain modules; - migrating static SQL to
sqlx::query!/sqlx::query_as!where possible; - replacing SHA-256 key derivation in
SecretCryptowith HKDF; - replacing string timestamps in domain models with typed timestamps;
- adding
Displaysupport todefine_id!; - introducing explicit PostgreSQL pool configuration;
- removing duplicated SQL timestamp formatting;
- making runtime and API error payloads more structured;
- introducing correlation IDs across transport, runtime, and adapters;
- adding rate limiting and backpressure controls;
- replacing in-memory MCP session state with a multi-instance-safe store.
Guiding Rules
- Keep domain logic separate from SQL storage concerns.
- Preserve current behavior while shrinking file size and coupling.
- Move in vertical slices: storage module split first, then SQL typing, then timestamp migration.
- Every slice must be independently reviewable and mergeable.
- Prefer compatibility shims during migration instead of breaking multiple crates at once.
- Add or update tests in the same slice as the refactoring.
Refactoring Tracks
Track A: Registry Modularization
Problem
crates/crank-registry/src/postgres.rs currently concentrates almost all database logic:
- bootstrap and auth;
- workspaces and memberships;
- invitations;
- operations and versions;
- agents and bindings;
- samples and descriptors;
- API keys;
- secrets;
- stream sessions and async jobs;
- invocation logs and usage;
- YAML import jobs.
This creates:
- a permanent git conflict hotspot;
- slow code navigation;
- oversized review diffs;
- accidental coupling between unrelated domains.
Target Layout
crates/crank-registry/src/
error.rs
lib.rs
migrations.rs
model.rs
postgres/
mod.rs
connect.rs
auth.rs
workspace.rs
invitation.rs
secret.rs
stream.rs
api_key.rs
operation.rs
agent.rs
observability.rs
yaml_import.rs
shared.rs
Ownership by Module
postgres/mod.rs
Responsibilities:
- define
PostgresRegistry; - wire submodules;
- expose the public impl surface;
- keep only high-level constructor entry points.
Functions staying here:
newconnectconnect_with_optionsmigrate
Functions moving out:
- everything domain-specific.
postgres/connect.rs
Responsibilities:
- connection setup;
- pool initialization;
- migration invocation;
- pool option translation from config to
PgPoolOptions.
Functions:
connectconnect_with_optionsmigrate- internal helpers:
build_pool_optionsbuild_connect_options
postgres/auth.rs
Responsibilities:
- auth users;
- user sessions;
- membership access checks;
- auth profiles.
Functions:
upsert_bootstrap_userget_auth_user_by_emailget_auth_user_by_idupdate_user_profileupdate_user_passwordcreate_user_sessionget_user_sessiontouch_user_sessionrevoke_user_sessionset_user_session_current_workspaceuser_has_workspace_accesssave_auth_profileget_auth_profilelist_auth_profileslist_auth_profiles_referencing_secret
Internal helpers to extract:
map_auth_user_rowmap_session_rowmap_auth_profile_row
postgres/workspace.rs
Responsibilities:
- workspaces;
- memberships;
- workspace CRUD.
Functions:
list_workspaceslist_workspaces_for_userensure_membershiplist_membershipsupdate_membership_roledelete_membershipget_workspacecreate_workspaceupdate_workspacedelete_workspace
Internal helpers:
workspace_existsmap_workspace_rowmap_membership_row
postgres/invitation.rs
Responsibilities:
- invitations lifecycle.
Functions:
list_invitationscreate_invitationdelete_invitation
postgres/secret.rs
Responsibilities:
- secrets;
- secret versions;
- secret touch/rotation.
Functions:
list_secretsget_secretget_current_secret_versioncreate_secretrotate_secretdelete_secrettouch_secret
postgres/stream.rs
Responsibilities:
- stream sessions;
- async jobs;
- state transitions;
- cleanup.
Functions:
create_stream_sessionget_stream_sessionupdate_stream_session_stateclose_stream_sessionlist_stream_sessionsdelete_expired_stream_sessionscreate_async_jobget_async_jobupdate_async_job_statuscancel_async_joblist_async_jobsdelete_expired_async_jobs
Internal helpers:
assert_stream_session_transitionassert_async_job_transitionmap_stream_session_rowmap_async_job_row
postgres/api_key.rs
Responsibilities:
- platform API key lifecycle.
Functions:
list_platform_api_keysget_platform_api_key_by_secret_for_workspace_slugcreate_platform_api_keyrevoke_platform_api_keydelete_platform_api_keytouch_platform_api_key
postgres/operation.rs
Responsibilities:
- operations;
- versions;
- published operations;
- samples;
- descriptors.
Functions:
create_operationcreate_versionupdate_operation_draftarchive_operationdelete_operationhas_published_agent_bindings_for_operationlist_operationslist_operation_usage_summarieslist_operation_agent_refsget_operation_summaryget_operation_versionlist_operation_versionspublish_operationget_published_operationlist_published_operationssave_sample_metadatalist_sample_metadatasave_descriptor_metadatalist_descriptor_metadatainsert_version_row
Internal helpers:
map_operation_summary_rowmap_operation_version_rowmap_published_operation_rowserialize_optional_json
postgres/agent.rs
Responsibilities:
- agents;
- agent versions;
- bindings;
- publish lifecycle.
Functions:
list_agentsget_agent_summarycreate_agentget_agent_versionsave_agent_bindingsupdate_agent_summarydelete_agentpublish_agentunpublish_agentarchive_agentget_published_agent_tools_by_sluglist_agent_bindingsinsert_agent_version_rowreplace_agent_bindings_rows
postgres/observability.rs
Responsibilities:
- invocation logs;
- usage rollups and breakdowns.
Functions:
create_invocation_loglist_invocation_logsget_invocation_logsummarize_usagelist_usage_timelinelist_usage_by_operationget_usage_for_operationlist_usage_by_agentget_usage_for_agent
postgres/yaml_import.rs
Responsibilities:
- YAML import jobs.
Functions:
create_yaml_import_jobfinish_yaml_import_jobget_yaml_import_job
postgres/shared.rs
Responsibilities:
- SQL snippets reused by several modules;
- common timestamp select fragments during the migration period;
- small row-mapping helpers;
- pagination helpers.
Planned content:
Page<T>helpers if they remain storage-local;- constants for table-independent fragments;
- transitional helpers until typed timestamps remove string formatting entirely.
Slice Plan for Track A
Slice A1: File layout and constructors
- create
postgres/directory; - move
connect,connect_with_options,migrate, and struct wiring; - keep all public signatures unchanged.
Slice A2: Workspace/Auth/API-key split
- move
auth.rs,workspace.rs,invitation.rs,api_key.rs; - leave behavior unchanged;
- add targeted module tests if not present.
Slice A3: Operation/Agent split
- move
operation.rs,agent.rs,yaml_import.rs; - keep SQL unchanged for this slice;
- ensure published-operation and published-agent behavior is unchanged.
Slice A4: Secret/Stream/Observability split
- move
secret.rs,stream.rs,observability.rs; - keep transition logic and cleanup behavior unchanged.
Slice A5: Remove legacy monolith
- remove remaining methods from root
postgres.rs; - rename module structure so
postgres/mod.rsis the only entry point.
Done Criteria for Track A
- no
postgres.rsmonolith remains; - no single registry source file exceeds roughly 800–1200 lines;
- all existing registry tests pass unchanged;
lib.rsstill re-exportsPostgresRegistrywithout downstream breakage.
Track B: Compile-Time SQL Verification
Problem
Most static SQL currently uses sqlx::query(...) / query_as::<_, T>(...) with runtime validation only. That leaves:
- column name typos;
- mismatched types;
- bad nullability assumptions;
- schema drift
to runtime instead of compile time.
Goal
Use sqlx::query! and sqlx::query_as! for every static query with stable shape.
Keep runtime builders only where the query shape is truly dynamic.
Query Classification
Must migrate to query! / query_as!
Use macros for:
- single-row fetches by ID;
- list queries with static columns;
- insert/update/delete with static statement shape;
- existence checks;
- count queries;
- cleanup queries with fixed predicates.
Expected modules:
auth.rsworkspace.rsinvitation.rssecret.rsstream.rsapi_key.rs- most of
agent.rs - most of
yaml_import.rs
May stay runtime-based
Keep dynamic APIs only when necessary:
- optional filters that materially change SQL shape;
- optional sort columns chosen from a whitelist;
- bulk dynamic
INlists where macro usage becomes unreadable; QueryBuilder-based multi-row inserts/updates.
Expected modules:
operation.rslist queries with optional filters;observability.rsusage aggregations and breakdowns;- some search/filter endpoints.
Required Infrastructure
Add SQLx offline support
Repository-level requirements:
- use
sqlx prepareto generate checked metadata; - keep checked metadata in version control if the team wants deterministic CI;
- wire verification into CI or
just verify.
Concrete additions:
sqlx-data.jsonor the current SQLx metadata equivalent for the chosen version;DATABASE_URLor test DB wiring forcargo sqlx prepare.
Introduce row structs where needed
For query_as!, add storage-local row structs for cases where domain structs should not be coupled directly to SQL column shape.
Examples:
WorkspaceRowAuthUserRowOperationSummaryRowInvocationLogRowUsageTimelineRow
Rule:
- domain structs remain domain-oriented;
- row structs remain SQL-oriented.
Function-Level Migration Rules
For query_as!
Use query_as! when:
- the query result maps cleanly to a typed row struct;
- all returned columns are explicit;
- nullability is known.
For query!
Use query! when:
- no reusable row struct is needed;
- the query is a simple
insert,update,delete, orexistsfetch.
For dynamic list queries
Use:
sqlx::QueryBuilder<Postgres>with explicit allowed sort/filter sets;- storage-local mapper functions from row to domain object.
Migration Order
Slice B1: auth, workspace, invitation
Low-risk, fixed-shape queries.
Slice B2: secret, stream, api_key, yaml_import
Still mostly fixed-shape.
Slice B3: operation, agent
Introduce mixed strategy: macros for fixed queries, builder for dynamic lists.
Slice B4: observability
Likely the most dynamic area. Convert only static pieces first, leave complex aggregations to QueryBuilder.
Done Criteria for Track B
- all fixed-shape SQL uses compile-time checked macros;
- dynamic SQL is explicitly documented and isolated;
- CI fails on SQL drift before runtime tests;
- no new static query is accepted through
sqlx::query(...)without justification.
Track C: SecretCrypto KDF Correction
Problem
Current code in crates/crank-runtime/src/secret_crypto.rs derives the AES key by hashing the master key with SHA-256:
- no KDF semantics;
- no labeled derivation context;
- no future rotation/versioning path.
Goal
Replace direct hashing with HKDF-SHA256.
Target Design
SecretCrypto::new(master_key: &str) will:
- validate non-empty master key;
- derive a 32-byte AES key using
Hkdf<Sha256>; - use explicit
infocontext, for example:b"crank.secret-envelope.v1"
- initialize
Aes256Gcmfrom the derived bytes.
Module Changes
File:
crates/crank-runtime/src/secret_crypto.rs
Changes:
- replace
Sha256::digest(trimmed.as_bytes())with HKDF expansion; - preserve the public constructor signature;
- keep ciphertext format unchanged if possible;
- if ciphertext format must change later, introduce envelope
versionbefore doing so.
Required Tests
encrypt_decrypt_roundtrip_still_worksempty_master_key_is_rejectedsame_master_key_derives_stable_keydifferent_master_keys_produce_different_ciphertexts
Done Criteria for Track C
- no direct SHA-256 KDF remains;
SecretCryptouses HKDF-SHA256 with explicit context;- all existing secret encrypt/decrypt flows remain compatible.
Track D: Typed Timestamps
Problem
Domain and storage structs use String timestamps. SQL converts timestamps via repeated to_char(...).
That causes:
- repeated formatting logic in SQL;
- weak type guarantees;
- inability to do time arithmetic without reparsing;
- broad surface for formatting drift.
Goal
Use time::OffsetDateTime in domain and storage models, with RFC 3339 serialization at the transport boundary.
Target Type Strategy
Domain layer
Use:
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime
and the same for updated_at, expires_at, used_at, and similar fields.
Storage row layer
Also use OffsetDateTime.
Do not format timestamps in SQL except for transitional compatibility shims.
Transport layer
JSON still emits RFC 3339 strings through Serde.
Files Affected
Domain crates
crates/crank-core/src/workspace.rscrates/crank-core/src/access.rscrates/crank-core/src/auth.rscrates/crank-core/src/agent.rscrates/crank-core/src/operation.rscrates/crank-core/src/observability.rscrates/crank-core/src/secret.rscrates/crank-core/src/stream_session.rs
Registry models
crates/crank-registry/src/model.rs
Admin API transport/service DTOs
apps/admin-api/src/service.rs- any route DTO files that mirror time fields directly
Migration Strategy
Slice D1: storage row types
- change storage row structs to
OffsetDateTime; - remove
to_char(...)from fixed-shape SQL; - keep service DTOs as strings temporarily if needed.
Slice D2: domain models
- convert
crank-coremodels to typed timestamps with Serde helpers; - update constructors and tests.
Slice D3: API/service transport
- remove string timestamp shims from service DTOs where possible;
- ensure JSON remains stable.
Slice D4: cleanup
- remove transitional formatting helpers;
- remove
now_string()helpers in favor of typed timestamps.
Acceptance Criteria
- no
created_at: Stringorupdated_at: Stringremains in domain/storage models; - no
to_char(created_at...)/to_char(updated_at...)remains in registry SQL except perhaps in legacy compatibility code slated for removal in the same slice; - JSON contract stays RFC 3339.
Track E: Display for ID Newtypes
Problem
define_id! currently requires .as_str() everywhere for formatting and messages.
Goal
Add std::fmt::Display support to every generated ID newtype.
File
crates/crank-core/src/ids.rs
Change
Add inside define_id!:
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
Follow-Up Cleanup
After macro support lands:
- replace formatting sites like
workspace_id.as_str()inside messages; - keep
.as_str()only where an actual&strAPI requires it.
Acceptance Criteria
- all ID newtypes implement
Display; - formatting code no longer uses
.as_str()insideformat!,println!, or error messages unless&stris actually required.
Track F: PostgreSQL Pool Configuration
Problem
The pool is currently created via default PgPoolOptions, which means the runtime behavior is implicit and under-configured.
Goal
Make pool behavior explicit and configurable from environment.
Target Config Model
Add a typed config struct, for example:
pub struct PostgresPoolConfig {
pub max_connections: u32,
pub min_connections: u32,
pub acquire_timeout_ms: u64,
pub idle_timeout_ms: u64,
pub max_lifetime_ms: u64,
}
Runtime Environment
Add env variables:
POSTGRES_MAX_CONNECTIONSPOSTGRES_MIN_CONNECTIONSPOSTGRES_ACQUIRE_TIMEOUT_MSPOSTGRES_IDLE_TIMEOUT_MSPOSTGRES_MAX_LIFETIME_MS
Files
crates/crank-registry/src/postgres/connect.rsapps/admin-api/src/main.rsapps/mcp-server/src/main.rs- config docs and examples
Behavior
connect_with_options should accept either:
- direct
PgConnectOptionsplus explicit pool config; - or an internal default config built from env.
Defaults should be conservative and explicit, for example:
max_connections = 20min_connections = 2acquire_timeout = 5sidle_timeout = 10mmax_lifetime = 30m
Exact values can be tuned later, but they must no longer be hidden defaults.
Acceptance Criteria
- pool limits and timeouts are explicit;
- docs reflect the new variables;
- startup logs or config inspection make active pool settings visible.
Track G: Timestamp Formatting Deduplication
Problem
The same to_char(... at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') fragment is copied across many queries.
Goal
Eliminate duplicated formatting logic.
Strategy
This track is mostly resolved by Track D, but there is a transition window.
During transition:
- centralize any remaining formatting in
postgres/shared.rs; - define named SQL fragments only once;
- remove inline duplicated snippets.
After Track D:
- remove formatting fragments entirely.
Acceptance Criteria
- duplicated inline timestamp formatting is no longer scattered across query strings;
- final state has typed timestamps instead of SQL formatting.
Track H: Error Structure Normalization
Problem
Some runtime and API error variants carry only a details: String, which makes programmatic handling and consistent observability harder.
Goal
Introduce structured error payloads where the current model is too string-heavy.
Scope
Priority types:
RuntimeError- registry storage errors where context is stringly typed;
- API error mapping around secret/runtime/streaming faults.
Design Rules
- keep
thiserror; - use structured fields in variants whenever stable context exists;
- reserve free-form
detailsonly for opaque third-party messages.
Examples:
Instead of:
RuntimeError::SecretCrypto { details: String }
prefer:
RuntimeError::SecretCrypto {
operation: &'static str,
details: String,
}
or dedicated variants such as:
InvalidSecretEnvelopeSecretDecryptFailedSecretSerializeFailed
Slice Plan
Slice H1
- normalize
SecretCryptoand streaming/session/job errors.
Slice H2
- normalize adapter errors for REST/gRPC/WebSocket/SOAP where stable categories exist.
Slice H3
- normalize API mapping and error codes.
Acceptance Criteria
- core runtime error variants expose structured context;
- API can map key failure classes deterministically;
- logs and telemetry can group errors by category instead of by ad hoc message text.
Track I: Correlation IDs and Cross-Layer Tracing
Problem
Today, a request can enter through:
admin-apiHTTP routes;mcp-serverJSON-RPC over Streamable HTTP;- internal runtime execution;
- protocol adapters (
REST,gRPC,WebSocket,SOAP)
without a single stable correlation key that survives the entire path.
There are already partial pieces in the codebase:
request_idexists in observability models and storage;- MCP mappings can inject
$.mcp.correlation_id; - some protocol fixtures mention
correlation_id.
But there is no guaranteed end-to-end propagation model. When an adapter fails, logs cannot always be tied back to:
- the incoming HTTP request;
- the MCP session;
- the runtime operation execution;
- the invocation log row.
Goal
Introduce a first-class correlation model that is generated once at ingress and propagated across:
- transport;
- service/runtime orchestration;
- adapter calls;
- persistence and logs.
Target Model
Introduce two related identifiers:
request_id- unique per incoming HTTP or MCP request;
- used for ingress/egress tracing and log correlation.
correlation_id- stable business-level identifier propagated into upstream payloads when configured;
- may equal
request_idby default, but remains distinct conceptually.
Flow Rules
admin-api
At HTTP ingress:
- read
X-Request-Idif supplied and valid; - otherwise generate a UUID v7 request ID;
- store it in request extensions;
- include it in structured logs;
- pass it into runtime execution context;
- persist it into
invocation_logs.request_idwhen a test run or runtime execution creates a log row.
mcp-server
At JSON-RPC ingress:
- derive a request-scoped
request_id; - bind it to the current transport session and JSON-RPC request;
- propagate it into runtime execution and adapter context;
- return it in logs and diagnostics.
Runtime
Add an execution context struct, for example:
pub struct ExecutionContext {
pub request_id: String,
pub correlation_id: String,
pub workspace_id: WorkspaceId,
pub agent_id: Option<AgentId>,
pub operation_id: OperationId,
pub mcp_session_id: Option<String>,
}
This context must be passed through:
- unary execution;
- window execution;
- session tool start/poll/stop;
- async job start/status/result/cancel.
Adapters
Each adapter must receive execution context and apply it consistently:
REST- add
X-Request-Id - add
X-Correlation-Id - allow explicit mapping into headers/query/body where configured
- add
gRPC- attach metadata keys such as
x-request-id,x-correlation-id
- attach metadata keys such as
WebSocket- inject correlation metadata into subscribe payloads when configured
SOAP- inject correlation value into configured header/body mappings
Files and Functions
New or expanded files
apps/admin-api/src/request_context.rsapps/mcp-server/src/request_context.rscrates/crank-runtime/src/context.rs- adapter request builders in each protocol crate
Functions to add or refactor
admin-api
extract_or_generate_request_id(...)request_context_middleware(...)runtime_request_context(...)
mcp-server
extract_mcp_request_context(...)runtime_request_context_from_message(...)
crank-runtime
ExecutionContextPreparedRequest/RuntimeOperationupdates to carry contextexecute_*functions accept&ExecutionContext
Acceptance Criteria
- every runtime invocation has a request ID;
- every adapter call can log the request ID;
- invocation logs persist the same request ID;
- operator can trace a failing adapter call back to the original HTTP or MCP request.
Track J: Rate Limiting and Backpressure
Problem
There is currently no explicit protection layer for:
- excessive API request rates;
- excessive tool invocation rates;
- excessive stream/session polling;
- unbounded adapter output pressure.
This is a production-readiness gap, especially now that the platform supports:
- unary execution;
- windowed streaming;
- session tools;
- async jobs;
- WebSocket and SSE upstream adapters.
Goal
Introduce explicit controls for request rate, concurrency, and bounded output pressure.
Scope
API-level controls
Apply to:
admin-apiroutes;mcp-servertransport endpoints.
Dimensions:
- per-IP
- per-session
- per-workspace
- per-platform-key
Runtime-level controls
Apply to:
- concurrent unary executions;
- concurrent window executions;
- concurrent live stream sessions;
- concurrent async jobs;
- polling frequency for session/job tools.
Adapter-level backpressure
Apply to:
- REST SSE
- gRPC server-streaming
- WebSocket windows/sessions
Controls:
- max buffered items
- max buffered bytes
- max window duration
- max session polls
- max per-message bytes
Target Design
admin-api
Add middleware with a keyed limiter, for example:
- by authenticated user ID;
- by workspace ID;
- by remote IP for unauthenticated endpoints.
mcp-server
Add a limiter keyed by:
- platform key ID;
- workspace slug;
- MCP session ID;
- optionally agent slug.
crank-runtime
Add a runtime guard layer, for example:
pub struct RuntimeLimits {
pub max_concurrent_unary: usize,
pub max_concurrent_window: usize,
pub max_concurrent_sessions: usize,
pub max_concurrent_jobs: usize,
pub max_polls_per_session: u32,
}
And an execution gate:
- acquire permit before execution;
- release permit on completion;
- reject with stable error code if limits are exceeded.
Implementation Slices
Slice J1: API request throttling
- add middleware and config for
admin-api; - add middleware and config for
mcp-server.
Slice J2: Runtime concurrency limits
- add semaphores/permits around execution paths.
Slice J3: Streaming/session pressure limits
- cap poll frequency and active session count;
- cap pending buffer sizes in adapters.
Acceptance Criteria
- platform can reject overload deterministically instead of degrading unpredictably;
- stream/session polling is bounded;
- slow clients cannot force unbounded buffering in streaming adapters.
Track K: Distributed MCP Session Store
Problem
apps/mcp-server/src/session.rs currently keeps transport/session state in process memory through Arc<RwLock<HashMap<...>>>.
This is acceptable only for a single instance. It breaks when:
- multiple
mcp-serverinstances run behind a load balancer; - a container restarts and loses in-memory session state;
- GET/POST/DELETE transport requests land on different instances.
Goal
Replace the in-memory session store with a multi-instance-safe shared store.
Target Design
Introduce a SessionStore trait:
#[async_trait]
pub trait SessionStore {
async fn create_transport_session(&self, session: TransportSessionRecord) -> Result<...>;
async fn get_transport_session(&self, session_id: &str) -> Result<...>;
async fn touch_transport_session(&self, session_id: &str, now: OffsetDateTime) -> Result<...>;
async fn delete_transport_session(&self, session_id: &str) -> Result<...>;
async fn append_pending_message(&self, session_id: &str, message: Value) -> Result<...>;
async fn drain_pending_messages(&self, session_id: &str) -> Result<Vec<Value>, ...>;
async fn cleanup_expired_transport_sessions(&self, now: OffsetDateTime) -> Result<u64, ...>;
}
Provide two implementations:
InMemorySessionStore- test-only and local dev fallback
PostgresSessionStore- production default
Storage Model
Add tables such as:
mcp_transport_sessionsmcp_transport_outbox
Suggested fields:
mcp_transport_sessions
idworkspace_idagent_idplatform_api_key_idtransport_kindstatuscreated_atupdated_atexpires_at
mcp_transport_outbox
idsession_idsequence_nopayload_jsoncreated_at
Semantics
POST initialize
- create transport session record in shared store.
GET SSE stream
- attach to existing session record;
- stream messages from outbox or polling bridge;
- support reconnect without requiring process-local state.
POST JSON-RPC
- resolve session from shared store;
- append outbound messages to outbox.
DELETE
- mark session closed in shared store;
- best-effort cleanup of outbox rows.
Files and Modules
Add:
apps/mcp-server/src/session/mod.rsapps/mcp-server/src/session/store.rsapps/mcp-server/src/session/in_memory.rsapps/mcp-server/src/session/postgres.rs
Potential shared data types:
crates/crank-core/src/transport_session.rs
Migration Order
Slice K1: trait and in-memory implementation
- preserve current behavior but hide it behind a trait.
Slice K2: persistent schema and Postgres store
- add migrations and registry/storage support.
Slice K3: transport handlers on shared store
- wire GET/POST/DELETE through the trait-backed store.
Slice K4: cleanup and failover semantics
- cleanup job for expired transport sessions;
- reconnect tests across store-backed state.
Acceptance Criteria
- transport session state survives process restart if store is persistent;
- GET/POST/DELETE requests can hit different instances safely;
- in-memory session state is no longer the production bottleneck.
Delivery Order
Recommended order:
feat/refactoring-planfeat/id-display-supportfeat/postgres-pool-configfeat/postgres-registry-modularizationfeat/sqlx-compile-time-verificationfeat/secret-crypto-hkdffeat/typed-timestampsfeat/error-structure-normalizationfeat/correlation-id-propagationfeat/runtime-rate-limiting-and-backpressurefeat/distributed-mcp-session-storefeat/post-refactor-cleanup
Reasoning:
Displayis tiny and immediately reduces noise.- pool config is operational and isolated.
- module split should happen before large query migrations, otherwise the macro migration lands into the monolith and becomes harder to review.
- HKDF is isolated and low-risk.
- typed timestamps should land after storage modules are split and query migration is underway.
Per-Slice Verification
Every slice must run:
just fmt-checkjust checkjust clippy- targeted tests for the touched area
Additionally:
- SQL macro migration slices must run SQLx prepare/verification;
- timestamp slices must run serialization regression tests;
- secret crypto slice must run compatibility and roundtrip tests.
Success Definition
The refactoring track is complete when:
- registry storage code is split into domain modules;
- static SQL is compile-time validated;
SecretCryptouses HKDF instead of SHA-256 as a KDF;- domain/storage timestamps are typed;
- ID newtypes implement
Display; - PostgreSQL pool settings are explicit and configurable;
- timestamp formatting duplication is removed;
- error variants carry structured context where it matters.
At that point, maintainability improves materially without changing the product model or feature scope.