# 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, /// Signal types explicitly blocked from writes, regardless of allow list. pub denied_write_signals: Vec, /// Signal types members with this role may read (via ranking queries). /// Empty = read access to all signals (default-open). pub allowed_read_signals: Vec, /// Signal types suppressed from ranking reads. /// Takes precedence over allowed_read_signals. pub denied_read_signals: Vec, } ``` ### 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` 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.