12 KiB
Design: Community Policy Engine
Overview
This is a pure backend feature. No UI. Design covers data structures, module layout, control flow, and integration points with existing code.
Architecture
The community policy engine follows the same pattern as the existing AgentPolicy / PolicyEvaluator pairing, but operates at the community (schema) layer rather than the per-session layer.
Dependency chain placement
schema/validation/community_policy.rs ← CommunityPolicy, CommunityContext, PolicyEntry
schema/validation/builders.rs ← SchemaBuilder::community_policy() method
schema/validation/mod.rs ← re-exports CommunityPolicy, CommunityContext
schema/mod.rs ← re-exports from validation
db/community.rs ← TidalDb::signal_with_community_policy() + evaluator call
ranking/executor/context.rs ← CommunityContext threading, suppressed_signals set
query/retrieve/types.rs ← RetrieveBuilder::community() method
No new crate dependencies. All types live in existing modules.
Data Structures
CommunityPolicy (schema/validation/community_policy.rs)
#[derive(Debug, Clone)]
pub struct CommunityPolicy {
pub allowed_write_signals: Vec<String>, // empty = no writes allowed
pub denied_write_signals: Vec<String>, // always blocked
pub allowed_read_signals: Vec<String>, // empty = all readable
pub denied_read_signals: Vec<String>, // always suppressed from scoring
}
Write semantics: deny > allow > default-allow.
- Deny list takes precedence.
- If allow list non-empty, signal must be in it.
- If both empty: all writes allowed (admin-like default).
Read semantics: deny > allow > default-allow.
- Same precedence, but applied to ranking score suppression.
CommunityContext (schema/validation/community_policy.rs)
#[derive(Debug, Clone)]
pub struct CommunityContext {
pub community_id: String, // for tracing; not validated against stored communities
pub role: String, // must match a CommunityPolicy name in schema
}
The database does not maintain a community registry. community_id is advisory — used for structured logging only. role is validated against registered CommunityPolicy names at call time.
Internal policy entry (schema/validation/community_policy.rs)
pub(super) struct CommunityPolicyEntry {
pub(super) name: String,
pub(super) policy: CommunityPolicy,
}
Stored in SchemaBuilder::community_policies: Vec<CommunityPolicyEntry>, converted to HashMap<String, CommunityPolicy> at build() time and stored in Schema.
Schema additions (schema/validation/mod.rs)
pub struct Schema {
// existing fields ...
community_policies: HashMap<String, CommunityPolicy>,
}
impl Schema {
pub fn community_policy(&self, role: &str) -> Option<&CommunityPolicy> {
self.community_policies.get(role)
}
pub fn community_policy_count(&self) -> usize {
self.community_policies.len()
}
}
PolicyViolationKind additions (session/policy.rs)
Two new variants added to the existing enum:
pub enum PolicyViolationKind {
Expired,
CountCap,
Denied,
NotAllowed,
// New:
CommunityWriteDenied, // signal in denied_write_signals
CommunityWriteNotAllowed, // signal not in non-empty allowed_write_signals
}
Write Enforcement
CommunityPolicyEvaluator (db/community.rs)
pub struct CommunityPolicyEvaluator<'a> {
policy: &'a CommunityPolicy,
role: &'a str,
}
impl<'a> CommunityPolicyEvaluator<'a> {
pub fn check_write(&self, signal_type: &str) -> Result<(), PolicyViolation> {
// 1. Deny list first
if self.policy.denied_write_signals.iter().any(|s| s == signal_type) {
return Err(PolicyViolation {
kind: PolicyViolationKind::CommunityWriteDenied,
signal_type: signal_type.to_owned(),
policy_name: self.role.to_owned(),
reason: format!("signal '{signal_type}' denied by community role '{}'", self.role),
});
}
// 2. Allow list (empty = all allowed)
if !self.policy.allowed_write_signals.is_empty()
&& !self.policy.allowed_write_signals.iter().any(|s| s == signal_type)
{
return Err(PolicyViolation {
kind: PolicyViolationKind::CommunityWriteNotAllowed,
signal_type: signal_type.to_owned(),
policy_name: self.role.to_owned(),
reason: format!("signal '{signal_type}' not in allowed writes for role '{}'", self.role),
});
}
Ok(())
}
}
TidalDb::signal_with_community_policy() (db/community.rs)
pub fn signal_with_community_policy(
&self,
signal_type: &str,
entity_id: EntityId,
weight: f64,
timestamp: Timestamp,
ctx: CommunityContext,
) -> crate::Result<()> {
// Resolve policy
let schema = self.schema();
let policy = schema
.community_policy(&ctx.role)
.ok_or_else(|| TidalError::NotFound(format!("community role '{}'", ctx.role)))?;
// Check write rules
let evaluator = CommunityPolicyEvaluator { policy, role: &ctx.role };
evaluator.check_write(signal_type).map_err(|v| TidalError::PolicyViolation {
reason: v.reason,
})?;
// Delegate to existing signal write path
self.signal(signal_type, entity_id, weight, timestamp)
}
TidalError already has a PolicyViolation variant (from session policy). Reuse it.
Read Enforcement
Threading CommunityContext through retrieval
RetrieveBuilder gains a community(ctx: CommunityContext) method. The Retrieve struct gains community: Option<CommunityContext>.
The ProfileExecutor receives CommunityContext via ExecutorContext. Before the scoring loop:
let suppressed: HashSet<SignalTypeId> = if let Some(ctx) = &executor_ctx.community {
let policy = schema.community_policy(&ctx.role)
.ok_or_else(|| QueryError::NotFound(format!("community role '{}'", ctx.role)))?;
build_suppressed_set(policy, schema)
} else {
HashSet::new()
};
Where build_suppressed_set resolves signal names from the deny/allow read lists to SignalTypeId values using schema.resolve_signal_type().
In the scoring loop:
for signal_type_id in signal_contributions {
if suppressed.contains(&signal_type_id) {
continue; // skip this signal's contribution to the score
}
// ... normal scoring ...
}
Fast path: if suppressed.is_empty() (no community context, or policy suppresses nothing), skip the contains check entirely with a branch on suppressed.is_empty().
Schema Validation
Added to SchemaBuilder::build() after signal and agent policy validation:
let mut seen_community_names = HashSet::new();
let mut community_policies = HashMap::new();
for entry in self.community_policies {
// Name validation (reuses existing is_valid_signal_name)
if !is_valid_signal_name(&entry.name) {
return Err(SchemaError::InvalidCommunityPolicyName(entry.name));
}
// Duplicate check
if !seen_community_names.insert(entry.name.clone()) {
return Err(SchemaError::DuplicateCommunityPolicyName(entry.name));
}
// All referenced signals must exist
for sig in all_signal_refs(&entry.policy) {
if !signals.contains_key(sig) {
return Err(SchemaError::CommunityPolicySignalNotInSchema {
policy: entry.name.clone(),
signal: sig.to_owned(),
});
}
}
// Write allow/deny conflict
for sig in &entry.policy.allowed_write_signals {
if entry.policy.denied_write_signals.contains(sig) {
return Err(SchemaError::CommunityPolicySignalConflict {
policy: entry.name.clone(),
signal: sig.clone(),
});
}
}
// Read allow/deny conflict
for sig in &entry.policy.allowed_read_signals {
if entry.policy.denied_read_signals.contains(sig) {
return Err(SchemaError::CommunityPolicySignalConflict {
policy: entry.name.clone(),
signal: sig.clone(),
});
}
}
community_policies.insert(entry.name, entry.policy);
}
New SchemaError variants:
InvalidCommunityPolicyName(String),
DuplicateCommunityPolicyName(String),
CommunityPolicySignalNotInSchema { policy: String, signal: String },
CommunityPolicySignalConflict { policy: String, signal: String },
Control Flow Diagram
Write path with community context
TidalDb::signal_with_community_policy(signal_type, entity_id, weight, ts, ctx)
│
├─ schema.community_policy(ctx.role)
│ → None → TidalError::NotFound
│ → Some(policy)
│
├─ CommunityPolicyEvaluator::check_write(signal_type, policy)
│ → Err(violation) → TidalError::PolicyViolation
│ → Ok(())
│
└─ self.signal(signal_type, entity_id, weight, ts) [existing path]
Read path with community context
TidalDb::retrieve(Retrieve { community: Some(ctx), ... })
│
└─ QueryExecutor::execute(...)
│
└─ ProfileExecutor::score_candidates(ctx, candidates)
│
├─ schema.community_policy(ctx.role)
│ → None → QueryError::NotFound
│ → Some(policy)
│
├─ build_suppressed_set(policy, schema) → HashSet<SignalTypeId>
│
└─ for each candidate:
for each signal_type_id:
if suppressed.contains(signal_type_id): skip
else: apply score contribution
File Changes Summary
| File | Change |
|---|---|
tidal/src/schema/validation/community_policy.rs |
New: CommunityPolicy, CommunityContext, CommunityPolicyEntry |
tidal/src/schema/validation/builders.rs |
Add community_policies field + community_policy() method + build validation |
tidal/src/schema/validation/mod.rs |
Add community_policies to Schema; re-export CommunityPolicy, CommunityContext |
tidal/src/schema/error.rs |
Add 4 new SchemaError variants |
tidal/src/schema/mod.rs |
Re-export CommunityPolicy, CommunityContext |
tidal/src/session/policy.rs |
Add 2 new PolicyViolationKind variants |
tidal/src/db/community.rs |
New: CommunityPolicyEvaluator::check_write() |
tidal/src/query/retrieve/types.rs |
Add community: Option<CommunityContext> to Retrieve; RetrieveBuilder::community() |
tidal/src/ranking/executor/ |
Scoring loop: check suppressed set per signal contribution |
tidal/tests/m10_community_policy.rs |
New: integration test suite (9 scenarios from spec) |
Testing Strategy
Unit tests live in community_policy.rs and builders.rs:
CommunityPolicyEvaluator::check_writefor all 3 outcomes (allow, deny-list, allow-list miss)- Schema validation rejection for each new error variant
Integration tests in tidal/tests/m10_community_policy.rs:
- All 9 scenarios from the spec test matrix
- Specifically: suppressed read signals produce lower ranking scores than unsuppressed, verified by scoring two candidates identically configured except one has a suppressed signal contribution
Risks and Mitigations
| Risk | Mitigation |
|---|---|
suppressed_signals lookup adds latency to scoring hot path |
Build set once per query, not per candidate; HashSet::contains is O(1) |
signal_with_community_policy API confusion vs signal |
Clear doc comments; signal remains preferred for non-community use cases |
CommunityContext.community_id field adds overhead without being validated |
Keep it String, used only for tracing::instrument span attribute |
| Read suppression silently changes ranking without caller awareness | QueryStats should log suppressed signal count (future enhancement; out of scope here) |