Action Items and Internal Exchange Features
This document describes the architecture and implementation of the Action Item Tracker and Internal Exchange (Listings) features in ICN Gateway.
Overview
These features address two key cooperative needs:
- Action Items: Track tasks and commitments from governance meetings
- Internal Exchange: Post offers/wants before going to external markets, keeping value circulating within the network
Action Items
Purpose
Action items help cooperatives track tasks that arise from board meetings, proposals, and day-to-day operations. They provide accountability by assigning items to members and tracking completion.
Data Model
ActionItem {
id: ActionItemId, // Unique identifier (UUID)
domain_id: GovernanceDomainId, // Governance domain this belongs to
title: String, // Short title (max 200 chars)
description: Option<String>, // Full description (max 5000 chars)
assignee: Option<Did>, // Who is responsible
due_date: Option<u64>, // Unix timestamp deadline
priority: ActionItemPriority, // High, Medium, Low
status: ActionItemStatus, // Pending, InProgress, Completed, Deferred
created_by: Did, // Who created this
created_at: u64, // Creation timestamp
updated_at: u64, // Last update timestamp
linked_proposal: Option<ProposalId>, // Associated proposal
meeting_context: Option<String>, // e.g., "2026-01-17 board meeting"
tags: Vec<String>, // Categorization tags
}
Storage
Sled Key Format: v1:action_item:{domain_id}:{item_id}
The v1: prefix enables future schema migrations. When the data model changes:
- Increment to
v2: - Add migration logic to read
v1:keys and convert tov2: - Eventually deprecate
v1:support
Authorization
| Operation | Who Can Perform |
|---|---|
| Create | Any domain member with governance:write scope |
| Read | Any domain member with governance:read scope |
| Update | Creator only |
| Update Status | Creator OR Assignee |
| Delete | Creator only |
Filtering & Sorting
Action items support filtering by:
status: Pending, InProgress, Completed, Deferredassignee: Filter by assigned DIDpriority: High, Medium, Lowoverdue: Boolean flag for items past due datetag: Filter by specific taglinked_proposal: Filter by associated proposal
Default sort order: due_date ASC, then priority DESC, then created_at DESC
Overdue Logic
An item is considered overdue when:
now > due_date // Strictly greater than, not at the deadline
This means items are NOT overdue AT the deadline, only AFTER it passes.
Internal Exchange (Listings)
Purpose
The internal exchange enables cooperatives to post offers and wants before going to external markets. When a coop has an unused asset (equipment, surplus materials, space), they can check if another network coop needs it first, keeping value circulating within the network.
Data Model
Listing {
id: ListingId, // Unique identifier (UUID)
listing_type: ListingType, // Offer or Want
title: String, // Short title (max 200 chars)
description: String, // Full description (max 5000 chars)
category: ListingCategory, // Equipment, Services, Materials, Space, Other
photos: Vec<String>, // URLs (https:// or ipfs://)
offered_by: Did, // Who posted this
coop_id: String, // Which coop this belongs to
seeking: String, // What they want in exchange (max 1000 chars)
visibility: ListingVisibility, // Coop, Federation, Network
status: ListingStatus, // Active, Matched, Completed, Expired, Cancelled
created_at: u64, // Creation timestamp
updated_at: u64, // Last update timestamp
expires_at: Option<u64>, // Optional expiry (max 1 year in future)
tags: Vec<String>, // Searchability tags (max 15)
}
ListingInterest {
id: Uuid, // Unique identifier
listing_id: ListingId, // Which listing
from_did: Did, // Who expressed interest
from_coop: String, // Their coop
message: String, // Message to owner (max 2000 chars)
offer: Option<String>, // What they're offering (max 1000 chars)
created_at: u64, // When expressed
}
Storage
Sled Key Formats:
- Listings:
v1:listing:{id} - Interests:
v1:interest:{listing_id}:{interest_id} - Interest Index:
v1:interest_idx:{listing_id}:{from_did}(for duplicate prevention)
The interest index enables atomic duplicate detection using Sled's compare-and-swap operation.
Authorization
| Operation | Who Can Perform |
|---|---|
| Create Listing | Any member with coop:write scope |
| Read Listings | Any member with coop:read scope |
| Update Listing | Owner only |
| Delete Listing | Owner only |
| Express Interest | Any member except owner |
| View Interests | Listing owner only |
Privacy
Interest Count Privacy: Interest counts are only visible to listing owners. This prevents competitors from seeing market demand signals.
// In list_listings: only fetch counts for owned listings
let owned_listing_ids: Vec<ListingId> = listings
.iter()
.filter(|l| caller_did.as_ref().is_some_and(|did| did == &l.offered_by))
.map(|l| l.id)
.collect();
let interest_counts = mgr.get_interest_counts(&owned_listing_ids);
Race Condition Prevention
Duplicate interest prevention uses atomic operations:
In-Memory Store: Check and insert under single write lock
let mut interests = self.interests.write()?;
let existing = interests.entry(listing_id).or_default();
if existing.iter().any(|i| &i.from_did == from_did) {
return Ok(false); // Duplicate
}
existing.push(interest.clone());
Ok(true)
Sled Store: Compare-and-swap on index key
let index_key = format!("v1:interest_idx:{}:{}", listing_id, from_did);
let cas_result = db.compare_and_swap(index_key, None::<&[u8]>, Some(b"1"))?;
if cas_result.is_err() {
return Ok(false); // Key existed, duplicate
}
// CAS succeeded, insert actual interest data
Photo URL Security
Photo URLs undergo comprehensive validation:
- Scheme Validation: Only
https://andipfs://allowed - SSRF Protection: Blocks private/internal IP addresses:
- RFC1918 private ranges (10.x, 172.16-31.x, 192.168.x)
- Loopback (127.x.x.x, ::1)
- Link-local (169.254.x.x, fe80::/10)
- Carrier-grade NAT (100.64.0.0/10)
- Documentation ranges
- Hostname Blocking: Rejects localhost, .local, .internal, .localhost
- Format Validation: Proper URL parsing, no CRLF injection
Pagination
Both features support pagination:
limit: Max items per page (default 20, max 100)offset: Number of items to skip
pub const MAX_PAGE_SIZE: usize = 100;
pub const DEFAULT_PAGE_SIZE: usize = 20;
API Endpoints
Action Items
POST /gov/domains/{domain_id}/action-items - Create action item
GET /gov/domains/{domain_id}/action-items - List with filters
GET /gov/domains/{domain_id}/action-items/{id} - Get by ID
PUT /gov/domains/{domain_id}/action-items/{id} - Update
DELETE /gov/domains/{domain_id}/action-items/{id} - Delete
Listings
POST /listings - Create listing
GET /listings - Browse (filters: type, category, status, coop, tag)
GET /listings/my - Get caller's listings
GET /listings/{id} - Get details
PUT /listings/{id} - Update
DELETE /listings/{id} - Delete
PUT /listings/{id}/status - Update status (matched/completed/cancelled)
POST /listings/{id}/interest - Express interest
GET /listings/{id}/interests - Get interests (owner only)
Storage Abstraction
Both features use trait-based storage abstraction:
pub trait ActionItemStoreBackend: Send + Sync {
fn save(&self, item: &ActionItem) -> Result<()>;
fn get(&self, domain_id: &GovernanceDomainId, id: &ActionItemId) -> Result<Option<ActionItem>>;
fn list(&self, domain_id: &GovernanceDomainId, filter: &ActionItemFilter) -> Result<Vec<ActionItem>>;
fn delete(&self, domain_id: &GovernanceDomainId, id: &ActionItemId) -> Result<bool>;
}
pub trait ListingsStoreBackend: Send + Sync {
fn save(&self, listing: &Listing) -> Result<()>;
fn get(&self, id: &ListingId) -> Result<Option<Listing>>;
fn list(&self, filter: &ListingFilter) -> Result<Vec<Listing>>;
fn delete(&self, id: &ListingId) -> Result<bool>;
fn add_interest(&self, interest: &ListingInterest) -> Result<()>;
fn get_interests(&self, listing_id: &ListingId) -> Result<Vec<ListingInterest>>;
fn add_interest_if_not_duplicate(&self, interest: &ListingInterest, from_did: &Did) -> Result<bool>;
}
This enables:
- In-memory storage for testing (fast, isolated)
- Sled storage for production (persistent, crash-safe)
- Future backends (PostgreSQL, etc.) without API changes
Performance Considerations
Batch Operations
Interest counts are fetched in batch to avoid N+1 queries:
pub fn get_interest_counts(&self, listing_ids: &[ListingId]) -> HashMap<ListingId, usize>
Efficient Filtering
The offered_by filter enables server-side "my listings" queries instead of fetching all listings and filtering client-side.
Sorting
Action items are sorted by due_date, priority, then created_at. Listings are sorted by created_at descending (newest first).
Future Optimization
For datasets exceeding 10K items, consider adding secondary indexes for common filters (status, assignee, category). Current implementation uses scan_prefix which is O(n).
Orphan Cleanup
When a listing is deleted, associated data is cleaned up:
pub fn delete(&self, id: &ListingId) -> Result<bool> {
// Remove listing
let existed = self.db.remove(listing_key)?;
// Clean up interests
for item in self.db.scan_prefix(interest_prefix) {
self.db.remove(key)?;
}
// Clean up interest index keys
for item in self.db.scan_prefix(interest_idx_prefix) {
self.db.remove(key)?;
}
Ok(existed)
}
Frontend Integration
The pilot-ui implements these features with:
- Action Items Tab: List view with filters, quick-add, status updates
- Exchange Tab: Card-based listings, photo galleries, interest forms
Security measures:
- All user content escaped with
escapeHtml()before rendering - Uses
textContentinstead ofinnerHTMLfor dynamic content - Input validation mirrors backend constraints
Operations
Listing Expiry Management
Listings can have optional expiry dates. The system provides two mechanisms for handling expired listings:
Automatic Filtering: Expired Active listings are automatically filtered out of list queries. Users won't see stale listings in search results, but the data is preserved.
Manual Cleanup: The
expire_stale_listings()method can be called to transition all expired Active listings to Expired status:
// In a maintenance task or admin endpoint
let count = listings_manager.expire_stale_listings()?;
tracing::info!("Expired {} stale listings", count);
For the pilot phase, this can be triggered manually via an admin endpoint or script. For production, consider adding a background task:
// Example: hourly expiry check
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
loop {
interval.tick().await;
if let Err(e) = listings_manager.expire_stale_listings() {
tracing::error!("Failed to expire listings: {}", e);
}
}
});
Performance Limits
Pilot Phase Limits (tested and supported):
- Up to 10,000 listings per deployment
- Up to 1,000 interests per listing
- Up to 100 active users per cooperative
Scaling Considerations:
- List queries scan all listings matching status filter - O(n) complexity
- For >10K listings, add secondary Sled indexes for status/category/offered_by
- Interest cleanup on listing delete is O(m) where m = interest count
- Batch operations (get_interest_counts) prevent N+1 query patterns
Rate Limiting
Interest submissions are protected by two layers of rate limiting:
DID-based Rate Limiting: Applied via middleware to all authenticated endpoints. Uses token bucket algorithm with 60 burst capacity and 6 req/sec sustained rate for write operations.
IP-based Rate Limiting: Applied specifically to the interest submission endpoint (
POST /listings/{id}/interest) for additional DoS protection. This prevents attackers from rotating DIDs to spam interest submissions.
Monitoring
Exchange Metrics (Prometheus):
icn_exchange_listings_created_total{listing_type, category}- Listing creation by type/categoryicn_exchange_listings_completed_total- Successful exchangesicn_exchange_listings_expired_total- Expired listings (from cleanup task)icn_exchange_interests_expressed_total{listing_type}- Interest expression activityicn_exchange_listings_active(gauge) - Current active listing counticn_exchange_listing_time_to_match_seconds(histogram) - Time to first interest match
Action Item Metrics (Prometheus):
icn_action_items_created_total{priority}- Action items created by priorityicn_action_items_completed_total- Completed action itemsicn_action_items_deferred_total- Deferred action itemsicn_action_items_status_changes_total{from_status, to_status}- Status transitionsicn_action_items_pending(gauge) - Current pending itemsicn_action_items_in_progress(gauge) - Current in-progress itemsicn_action_items_overdue(gauge) - Current overdue itemsicn_action_items_time_to_complete_seconds(histogram) - Time to completion
Example Prometheus Alert Rules
Add these to your Prometheus rules configuration for proactive monitoring:
groups:
- name: icn_exchange_alerts
rules:
# Alert if too many active listings accumulate (may indicate cleanup issues)
- alert: ExchangeListingsAccumulating
expr: icn_exchange_listings_active > 10000
for: 1h
labels:
severity: warning
annotations:
summary: "High number of active listings"
description: "Active listings ({{ $value }}) exceeds threshold. Check if expiry scheduler is running."
# Alert if listings aren't being completed (exchange not being used)
- alert: ExchangeLowCompletionRate
expr: |
rate(icn_exchange_listings_completed_total[24h]) == 0
and icn_exchange_listings_active > 10
for: 7d
labels:
severity: info
annotations:
summary: "No listings completed in 7 days"
description: "No exchange listings have been completed despite active inventory."
# Alert if many interests are being blocked as duplicates
- alert: ExchangeHighDuplicateInterests
expr: |
rate(icn_exchange_duplicate_interests_blocked_total[1h])
/ rate(icn_exchange_interests_expressed_total[1h]) > 0.5
for: 1h
labels:
severity: warning
annotations:
summary: "High duplicate interest rate"
description: "Over 50% of interests are duplicates. May indicate UI bug or abuse."
- name: icn_action_items_alerts
rules:
# Alert if overdue items are accumulating
- alert: ActionItemsOverdueAccumulating
expr: icn_action_items_overdue > 50
for: 24h
labels:
severity: warning
annotations:
summary: "Many overdue action items"
description: "{{ $value }} action items are overdue. Review and reschedule or complete."
# Alert if action items are being deferred frequently
- alert: ActionItemsHighDeferralRate
expr: |
rate(icn_action_items_deferred_total[7d])
/ rate(icn_action_items_created_total[7d]) > 0.3
for: 7d
labels:
severity: info
annotations:
summary: "High action item deferral rate"
description: "Over 30% of action items being deferred. May indicate capacity issues."
# Alert if high-priority items aren't being addressed
- alert: HighPriorityItemsStale
expr: |
increase(icn_action_items_completed_total{priority="high"}[7d]) == 0
and icn_action_items_pending{priority="high"} > 0
for: 7d
labels:
severity: warning
annotations:
summary: "High-priority action items not being completed"
description: "High-priority items exist but none completed in 7 days."
Adjust thresholds based on your cooperative's size and activity level.