253 lines
9.9 KiB
Markdown
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.
|