350 lines
12 KiB
Markdown
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) |
|