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

350 lines
12 KiB
Markdown

# 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<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)
```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<CommunityPolicyEntry>`, converted to `HashMap<String, CommunityPolicy>` at `build()` time and stored in `Schema`.
### `Schema` additions (schema/validation/mod.rs)
```rust
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:
```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<CommunityContext>`.
The `ProfileExecutor` receives `CommunityContext` via `ExecutorContext`. Before the scoring loop:
```rust
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:
```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<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) |