tidaldb/.sdlc/features/m10-community-policy-engine/design.md

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_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)