Storage Port API
The StoragePort trait defines the persistence abstraction for all domain entities. It provides async CRUD operations for features, work packages, audit entries, evidence, policy rules, and governance contracts. The trait has zero external dependencies and is implemented by agileplus-sqlite using SQLite.
Trait Definition
pub trait StoragePort: Send + Sync {
// -- Feature CRUD --
/// Create a new feature, returning its assigned ID.
fn create_feature(
&self,
feature: &Feature,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Look up a feature by its unique slug.
fn get_feature_by_slug(
&self,
slug: &str,
) -> impl Future<Output = Result<Option<Feature>, DomainError>> + Send;
/// Look up a feature by its primary key.
fn get_feature_by_id(
&self,
id: i64,
) -> impl Future<Output = Result<Option<Feature>, DomainError>> + Send;
/// Update only the state field of an existing feature.
fn update_feature_state(
&self,
id: i64,
state: FeatureState,
) -> impl Future<Output = Result<(), DomainError>> + Send;
/// List all features currently in the given state.
fn list_features_by_state(
&self,
state: FeatureState,
) -> impl Future<Output = Result<Vec<Feature>, DomainError>> + Send;
/// List every feature in the system.
fn list_all_features(&self) -> impl Future<Output = Result<Vec<Feature>, DomainError>> + Send;
// -- Work Package CRUD --
/// Create a new work package, returning its assigned ID.
fn create_work_package(
&self,
wp: &WorkPackage,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Look up a work package by primary key.
fn get_work_package(
&self,
id: i64,
) -> impl Future<Output = Result<Option<WorkPackage>, DomainError>> + Send;
/// Update only the state field of a work package.
fn update_wp_state(
&self,
id: i64,
state: WpState,
) -> impl Future<Output = Result<(), DomainError>> + Send;
/// List all work packages belonging to a feature.
fn list_wps_by_feature(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Vec<WorkPackage>, DomainError>> + Send;
/// Record a dependency between two work packages.
fn add_wp_dependency(
&self,
dep: &WpDependency,
) -> impl Future<Output = Result<(), DomainError>> + Send;
/// Get all dependencies for a given work package.
fn get_wp_dependencies(
&self,
wp_id: i64,
) -> impl Future<Output = Result<Vec<WpDependency>, DomainError>> + Send;
/// Get work packages whose dependencies are all in `Done` state.
fn get_ready_wps(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Vec<WorkPackage>, DomainError>> + Send;
// -- Audit CRUD --
/// Append an audit entry to the immutable log, returning its ID.
fn append_audit_entry(
&self,
entry: &AuditEntry,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Retrieve the full audit trail for a feature, ordered by timestamp.
fn get_audit_trail(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Vec<AuditEntry>, DomainError>> + Send;
/// Get the most recent audit entry for a feature.
fn get_latest_audit_entry(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Option<AuditEntry>, DomainError>> + Send;
// -- Evidence + Policy + Metric CRUD --
/// Store a new piece of evidence, returning its ID.
fn create_evidence(
&self,
evidence: &Evidence,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Get all evidence associated with a work package.
fn get_evidence_by_wp(
&self,
wp_id: i64,
) -> impl Future<Output = Result<Vec<Evidence>, DomainError>> + Send;
/// Get all evidence satisfying a given functional requirement.
fn get_evidence_by_fr(
&self,
fr_id: &str,
) -> impl Future<Output = Result<Vec<Evidence>, DomainError>> + Send;
/// Create a new policy rule, returning its ID.
fn create_policy_rule(
&self,
rule: &PolicyRule,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// List all currently active policy rules.
fn list_active_policies(
&self,
) -> impl Future<Output = Result<Vec<PolicyRule>, DomainError>> + Send;
/// Record a command-execution metric, returning its ID.
fn record_metric(
&self,
metric: &Metric,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Get all metrics associated with a feature.
fn get_metrics_by_feature(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Vec<Metric>, DomainError>> + Send;
// -- Governance --
/// Store a governance contract, returning its ID.
fn create_governance_contract(
&self,
contract: &GovernanceContract,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Look up a specific version of a governance contract for a feature.
fn get_governance_contract(
&self,
feature_id: i64,
version: i32,
) -> impl Future<Output = Result<Option<GovernanceContract>, DomainError>> + Send;
/// Get the latest governance contract for a feature.
fn get_latest_governance_contract(
&self,
feature_id: i64,
) -> impl Future<Output = Result<Option<GovernanceContract>, DomainError>> + Send;
}Key Domain Types
All types use strongly-typed IDs and enums to prevent invalid states.
Feature
pub struct Feature {
pub id: i64,
pub slug: String,
pub friendly_name: String,
pub state: FeatureState,
pub target_branch: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub enum FeatureState {
SPECIFY,
PLAN,
IMPLEMENT,
REVIEW,
DONE,
}WorkPackage
pub struct WorkPackage {
pub id: i64,
pub feature_id: i64,
pub sequence: u32, // 1, 2, 3...
pub title: String,
pub state: WpState,
pub agent_id: Option<String>,
pub pr_url: Option<String>,
pub file_scope: Vec<String>, // Authorized files
}
pub enum WpState {
PLANNED,
DOING,
FOR_REVIEW,
DONE,
}
pub struct WpDependency {
pub wp_id: i64,
pub depends_on: i64,
}Audit Trail (Immutable)
pub struct AuditEntry {
pub id: i64,
pub feature_id: i64,
pub wp_sequence: Option<u32>,
pub timestamp: DateTime<Utc>,
pub actor: String, // "claude-code", "user@example.com", etc.
pub transition: String, // "SPECIFY -> PLAN", "WP01: PLANNED -> DOING"
pub evidence_refs: Vec<String>, // ["pr/42", "commit/abc123"]
pub prev_hash: Vec<u8>, // SHA256 of previous entry
pub hash: Vec<u8>, // SHA256(entry) — cryptographic chain
}Governance
pub struct GovernanceContract {
pub id: i64,
pub feature_id: i64,
pub version: i32,
pub rules: Vec<PolicyRule>,
pub created_at: DateTime<Utc>,
}
pub struct PolicyRule {
pub id: i64,
pub rule_id: String, // "FR-REVIEW-001"
pub description: String,
pub is_active: bool,
}
pub struct Evidence {
pub id: i64,
pub wp_id: i64,
pub fr_id: String, // Requirement this satisfies
pub artifact_url: String, // PR, commit, test report URL
pub confidence: f64, // 0.0 to 1.0
}Metrics
pub struct Metric {
pub id: i64,
pub feature_id: i64,
pub wp_id: i64,
pub metric_type: String, // "lines_changed", "test_coverage", "review_time"
pub value: f64,
pub unit: String, // "lines", "%", "minutes"
pub recorded_at: DateTime<Utc>,
}Built-in Implementation: SqliteStorageAdapter
The default implementation uses SQLite with async queries via tokio-rusqlite.
use agileplus_sqlite::SqliteStorageAdapter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create or open database
let storage = SqliteStorageAdapter::new("agileplus.db")?;
// Create a feature
let feature = Feature {
id: 0,
slug: "001-login".to_string(),
friendly_name: "User Login System".to_string(),
state: FeatureState::SPECIFY,
target_branch: "main".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let feature_id = storage.create_feature(&feature).await?;
println!("Created feature with ID: {}", feature_id);
// Query features by state
let implementing = storage
.list_features_by_state(FeatureState::IMPLEMENT)
.await?;
println!("Features in IMPLEMENT: {}", implementing.len());
Ok(())
}Custom Implementations
To implement a custom storage backend (database, S3, Firestore):
1. Implement the Trait
use agileplus_domain::ports::StoragePort;
use agileplus_domain::domain::feature::Feature;
use agileplus_domain::error::DomainError;
pub struct MyCustomStorage {
// Your backend fields
}
#[async_trait::async_trait]
impl StoragePort for MyCustomStorage {
async fn create_feature(
&self,
feature: &Feature,
) -> Result<i64, DomainError> {
// Implement persistence logic
// Return the assigned feature ID
Ok(42)
}
// Implement all other methods...
}2. Wire into CLI
In main.rs:
let storage: Box<dyn StoragePort> = match backend_type {
"sqlite" => Box::new(SqliteStorageAdapter::new(db_path)?),
"my-custom" => Box::new(MyCustomStorage::new(config)?),
_ => panic!("unknown backend"),
};3. Configure
# .kittify/config.toml
[storage]
backend = "my-custom"
url = "postgresql://localhost/agileplus"Transaction Semantics
All operations are atomic at the database level:
- Feature state transitions are serialized (no concurrent updates)
- Audit entries are append-only with cryptographic chaining
- Work package state changes log an audit entry automatically
- Governance contract versions are immutable (new versions create new records)
Async Runtime
All trait methods return impl Future with Send bound, making them compatible with tokio and other async runtimes:
// Tokio runtime
#[tokio::main]
async fn main() -> Result<()> {
let features = storage.list_all_features().await?;
Ok(())
}
// Custom runtime
futures::executor::block_on(async {
let features = storage.list_all_features().await?;
})?;SyncMapping Operations
The StoragePort also manages sync mappings for external tracker integration:
/// Create a mapping between a feature/WP and an external issue.
fn create_sync_mapping(
&self,
mapping: &SyncMapping,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// Look up a sync mapping by feature and platform.
fn get_sync_mapping(
&self,
feature_id: i64,
platform: &str,
) -> impl Future<Output = Result<Option<SyncMapping>, DomainError>> + Send;
/// Update the external state in a sync mapping.
fn update_sync_mapping_state(
&self,
mapping_id: i64,
external_state: &str,
last_synced_at: DateTime<Utc>,
) -> impl Future<Output = Result<(), DomainError>> + Send;
/// List all sync mappings that are overdue for sync.
fn list_stale_sync_mappings(
&self,
older_than: Duration,
) -> impl Future<Output = Result<Vec<SyncMapping>, DomainError>> + Send;Example usage:
// After creating a Plane.so issue, record the mapping
let mapping = SyncMapping {
id: 0,
feature_id: feature.id,
wp_id: None,
platform: "plane".to_string(),
external_id: "AGILE-123".to_string(),
external_url: "https://app.plane.so/workspace/project/issues/AGILE-123".to_string(),
external_state: "Backlog".to_string(),
last_synced_at: Utc::now(),
sync_direction: SyncDirection::Bidirectional,
};
let mapping_id = storage.create_sync_mapping(&mapping).await?;
// Later, when Plane.so webhook fires:
storage.update_sync_mapping_state(
mapping_id,
"In Progress",
Utc::now(),
).await?;DeviceNode Operations
For P2P multi-device setups:
/// Register or update a device node.
fn upsert_device_node(
&self,
node: &DeviceNode,
) -> impl Future<Output = Result<i64, DomainError>> + Send;
/// List all active device nodes (seen within last N minutes).
fn list_active_devices(
&self,
active_within: Duration,
) -> impl Future<Output = Result<Vec<DeviceNode>, DomainError>> + Send;
/// Update a device node's vector clock.
fn update_device_clock(
&self,
device_id: &str,
clock: &VectorClock,
) -> impl Future<Output = Result<(), DomainError>> + Send;SQLite Schema Reference
The agileplus-sqlite adapter creates these tables via migrations in crates/agileplus-sqlite/src/migrations/:
-- 001_initial_schema.sql
CREATE TABLE features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
friendly_name TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'Created',
spec_hash BLOB,
target_branch TEXT NOT NULL DEFAULT 'main',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE work_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_id INTEGER NOT NULL REFERENCES features(id),
title TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'Planned',
sequence INTEGER NOT NULL,
file_scope TEXT NOT NULL DEFAULT '[]', -- JSON array
acceptance_criteria TEXT NOT NULL DEFAULT '',
agent_id TEXT,
pr_url TEXT,
pr_state TEXT,
worktree_path TEXT,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE wp_dependencies (
wp_id INTEGER NOT NULL REFERENCES work_packages(id),
depends_on INTEGER NOT NULL REFERENCES work_packages(id),
dep_type TEXT NOT NULL DEFAULT 'Explicit',
PRIMARY KEY (wp_id, depends_on)
);
-- 002_audit_chain.sql
CREATE TABLE audit_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_id INTEGER NOT NULL REFERENCES features(id),
wp_id INTEGER REFERENCES work_packages(id),
timestamp DATETIME NOT NULL,
actor TEXT NOT NULL,
transition TEXT NOT NULL,
prev_hash BLOB NOT NULL,
hash BLOB NOT NULL UNIQUE
);
CREATE TABLE evidence_refs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
audit_entry_id INTEGER NOT NULL REFERENCES audit_entries(id),
evidence_id INTEGER NOT NULL REFERENCES evidence(id),
fr_id TEXT NOT NULL
);
CREATE TABLE evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wp_id INTEGER NOT NULL REFERENCES work_packages(id),
fr_id TEXT NOT NULL,
evidence_type TEXT NOT NULL,
artifact_path TEXT NOT NULL,
metadata TEXT, -- JSON
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);Run migrations:
# Migrations run automatically on startup, or manually:
agileplus db migrate
# Check current migration version:
agileplus db version
# Output: Applied migrations: 1..5 (latest: 005_sync_mappings.sql)Next Steps
- VCS Port — VcsPort API reference
- MCP Tools — MCP tool catalog using StoragePort
- Domain Model — Full entity relationships
- Extending — Implementing custom storage backends
- Environment Variables — Database configuration