21 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.
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.
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/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.