# 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) ```rust #[derive(Debug, Clone)] pub struct CommunityPolicy { pub allowed_write_signals: Vec, // empty = no writes allowed pub denied_write_signals: Vec, // always blocked pub allowed_read_signals: Vec, // empty = all readable pub denied_read_signals: Vec, // 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) ```rust #[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) ```rust pub(super) struct CommunityPolicyEntry { pub(super) name: String, pub(super) policy: CommunityPolicy, } ``` Stored in `SchemaBuilder::community_policies: Vec`, converted to `HashMap` at `build()` time and stored in `Schema`. ### `Schema` additions (schema/validation/mod.rs) ```rust pub struct Schema { // existing fields ... community_policies: HashMap, } 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: ```rust 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) ```rust 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) ```rust 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`. The `ProfileExecutor` receives `CommunityContext` via `ExecutorContext`. Before the scoring loop: ```rust let suppressed: HashSet = 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: ```rust 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: ```rust 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: ```rust 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 │ └─ 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` 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_write` for 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) |