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

253 lines
9.9 KiB
Markdown

# Spec: Community Policy Engine
## Summary
Extend the existing agent-scoped `AgentPolicy` mechanism to cover community-level access control on signal types. Community policy is a declarative, schema-registered set of rules that governs which signal types any member of a community context can read and/or write. Policy is versioned alongside schema, enforced at the signal ingestion boundary, and attached to ranking queries that carry a community context.
This feature does not replace per-session `AgentPolicy`. It adds a parallel, coarser-grained layer: **who can write (or read) what** within a named community context, independently of any individual agent session.
---
## Problem
The current system enforces signal access only at the session (agent) layer via `AgentPolicy`. When a database is shared across multiple communities (subreddits, Discord servers, content channels, team spaces), there is no way to express:
- "Members of community `c` may write `vote` and `view` signals but never `hide` or `block`"
- "The `premium_like` signal can only be written by verified members"
- "Moderators can write `pin` signals; regular members cannot"
- "The `view` signal is publicly readable for ranking; the `private_reaction` signal is not"
Without community-level policy, the application must implement these rules in its own middleware — exactly the kind of logic that belongs in the database, not bolted on top.
---
## Goals
1. Allow operators to declare named `CommunityPolicy` objects in schema, specifying which signal types members may write and which they may read.
2. Enforce community write policy at signal ingestion time (before WAL append), returning a typed `PolicyViolation` on rejection.
3. Enforce community read policy at query time, filtering or suppressing signal-derived scores for signals the caller is not allowed to read.
4. Version community policy alongside schema — a schema change atomically updates all community policy rules.
5. Support role-based policy selection: different `CommunityPolicy` names map to different access tiers (e.g. `member`, `moderator`, `admin`).
---
## Non-Goals
- Dynamic policy updates without schema rebuild (outside this milestone).
- Per-user overrides within a community (handled by agent capability boundaries, a sibling feature).
- Community membership evaluation — the caller asserts their role; the database enforces the declared rules for that role.
- Audit logging of community policy violations (handled by the existing session audit log; out of scope here).
---
## Domain Model
### `CommunityPolicy`
A named policy object declared in schema:
```rust
pub struct CommunityPolicy {
/// Signal types members with this role may write.
/// Empty = no write access to any signal.
pub allowed_write_signals: Vec<String>,
/// Signal types explicitly blocked from writes, regardless of allow list.
pub denied_write_signals: Vec<String>,
/// Signal types members with this role may read (via ranking queries).
/// Empty = read access to all signals (default-open).
pub allowed_read_signals: Vec<String>,
/// Signal types suppressed from ranking reads.
/// Takes precedence over allowed_read_signals.
pub denied_read_signals: Vec<String>,
}
```
### Semantics
**Write enforcement (deny-first, then allow):**
1. If the signal type is in `denied_write_signals` → reject.
2. If `allowed_write_signals` is non-empty and the signal type is not in it → reject.
3. Otherwise → allow.
**Read enforcement (deny-first, then allow):**
1. If the signal type is in `denied_read_signals` → suppress from ranking scores.
2. If `allowed_read_signals` is non-empty and the signal type is not in it → suppress.
3. Otherwise → expose.
Read enforcement is applied in the ranking executor: scores derived from suppressed signals are not included in the candidate scoring pass.
### `CommunityContext`
A struct attached to ranking queries that specifies which policy to apply:
```rust
pub struct CommunityContext {
/// Name of the community (for tracing and logging).
pub community_id: String,
/// Policy name to apply for this caller.
/// Must match a `CommunityPolicy` registered in schema.
pub role: String,
}
```
---
## API Changes
### Schema declaration
```rust
let mut builder = SchemaBuilder::new();
// ... signal declarations ...
builder.community_policy("member", CommunityPolicy {
allowed_write_signals: vec!["view".to_string(), "vote".to_string()],
denied_write_signals: vec!["pin".to_string()],
allowed_read_signals: vec![], // all readable
denied_read_signals: vec!["private_reaction".to_string()],
});
builder.community_policy("moderator", CommunityPolicy {
allowed_write_signals: vec!["view".to_string(), "vote".to_string(), "pin".to_string()],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
});
let schema = builder.build()?;
```
### Signal write with community policy check
```rust
db.signal_with_community_policy(
"view",
entity_id,
1.0,
Timestamp::now(),
CommunityContext { community_id: "rust_lang".to_string(), role: "member".to_string() },
)?;
```
Returns `Err(TidalError::PolicyViolation(...))` if the write is blocked by community policy.
### Ranking query with community policy
```rust
let results = db.retrieve(
Retrieve::builder()
.for_user(user_id)
.profile("for_you")
.community(CommunityContext { community_id: "rust_lang".to_string(), role: "member".to_string() })
.limit(50)
.build()
)?;
```
The executor suppresses scoring contributions from signals in `denied_read_signals` for the specified role.
---
## Enforcement Points
### Write path
`TidalDb::signal_with_community_policy()` — new method. Before calling into the signal ledger:
1. Resolve `CommunityPolicy` by `role` name from schema.
2. Evaluate write rules via `CommunityPolicyEvaluator::check_write(signal_type, policy)`.
3. On violation: return `TidalError::PolicyViolation` with kind, signal type, and policy name.
4. On pass: proceed identically to `TidalDb::signal()`.
The existing `TidalDb::signal()` continues to work without community context (no policy applied).
### Read path
`ProfileExecutor::score_candidates()` — existing scoring pass. When a `CommunityContext` is present on the query:
1. Resolve `CommunityPolicy` from schema.
2. Build a `suppressed_signals: HashSet<SignalTypeId>` from the read rules.
3. In the scoring loop, skip any signal contribution whose `SignalTypeId` is in `suppressed_signals`.
No additional storage reads. The suppressed set is built once per query from in-memory schema.
---
## Schema Validation
At `SchemaBuilder::build()` time:
- All signal names in `allowed_write_signals`, `denied_write_signals`, `allowed_read_signals`, `denied_read_signals` must exist in the schema.
- No signal may appear in both the write allow and write deny lists.
- No signal may appear in both the read allow and read deny lists.
- Policy names must be valid identifiers (same rules as signal names).
- Duplicate policy names are rejected.
Errors: `SchemaError::InvalidCommunityPolicyName`, `SchemaError::DuplicateCommunityPolicyName`, `SchemaError::CommunityPolicySignalNotInSchema`, `SchemaError::CommunityPolicySignalConflict`.
---
## Module Placement
Following the existing architecture's dependency chain:
```
schema/validation/community_policy.rs ← CommunityPolicy, CommunityContext types
schema/validation/builders.rs ← SchemaBuilder::community_policy() method
db/community.rs ← signal_with_community_policy() implementation
ranking/executor/context.rs ← CommunityContext threading + suppressed_signals
```
No new crate dependencies required.
---
## Error Types
`TidalError::PolicyViolation` already exists (from session policy). It is reused. The `PolicyViolationKind` enum gains:
```rust
/// Community-level write policy rejected this signal type.
CommunityWriteDenied,
/// Signal type not in community write allow list.
CommunityWriteNotAllowed,
```
---
## Performance
Community policy evaluation on the write path is O(n) where n = length of the policy's signal lists. For realistic schemas (< 50 signal types), this is negligible.
Community read suppression on the ranking path: one `HashSet` construction (O(k) where k = denied signals) plus O(1) per scoring step per candidate. No additional storage I/O.
---
## Test Matrix
| Scenario | Expected |
|---|---|
| Write allowed signal under member role | Succeeds |
| Write denied signal under member role | `PolicyViolation(CommunityWriteDenied)` |
| Write signal not in allow list under member role | `PolicyViolation(CommunityWriteNotAllowed)` |
| Write any signal under admin role (no restrictions) | Succeeds |
| Read suppressed signal excluded from ranking score | Score component = 0 |
| Read suppressed signal with no community context | Score component included normally |
| Schema with duplicate policy name | `SchemaError::DuplicateCommunityPolicyName` |
| Schema with unknown signal in policy | `SchemaError::CommunityPolicySignalNotInSchema` |
| Schema with allow/deny conflict | `SchemaError::CommunityPolicySignalConflict` |
| Query with unknown role name | `TidalError::NotFound` |
---
## Acceptance Criteria
1. `SchemaBuilder::community_policy()` accepts a `CommunityPolicy` and validates it at `build()` time.
2. `TidalDb::signal_with_community_policy()` enforces write rules and returns typed `PolicyViolation` on rejection.
3. `Retrieve::builder().community()` threads `CommunityContext` to the executor, which suppresses denied-read signals from scoring.
4. All schema validation errors surface correctly (signal not in schema, conflict, duplicate name).
5. All 9 test matrix scenarios covered by integration tests in `tidal/tests/m10_community_policy.rs`.
6. Zero performance regression on the `retrieve()` hot path without a community context (no-op fast path).
7. `cargo test --lib` and integration tests pass; `clippy -D warnings` clean.