9.9 KiB
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
cmay writevoteandviewsignals but neverhideorblock" - "The
premium_likesignal can only be written by verified members" - "Moderators can write
pinsignals; regular members cannot" - "The
viewsignal is publicly readable for ranking; theprivate_reactionsignal 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
- Allow operators to declare named
CommunityPolicyobjects in schema, specifying which signal types members may write and which they may read. - Enforce community write policy at signal ingestion time (before WAL append), returning a typed
PolicyViolationon rejection. - Enforce community read policy at query time, filtering or suppressing signal-derived scores for signals the caller is not allowed to read.
- Version community policy alongside schema — a schema change atomically updates all community policy rules.
- Support role-based policy selection: different
CommunityPolicynames 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:
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):
- If the signal type is in
denied_write_signals→ reject. - If
allowed_write_signalsis non-empty and the signal type is not in it → reject. - Otherwise → allow.
Read enforcement (deny-first, then allow):
- If the signal type is in
denied_read_signals→ suppress from ranking scores. - If
allowed_read_signalsis non-empty and the signal type is not in it → suppress. - 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:
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
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
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
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:
- Resolve
CommunityPolicybyrolename from schema. - Evaluate write rules via
CommunityPolicyEvaluator::check_write(signal_type, policy). - On violation: return
TidalError::PolicyViolationwith kind, signal type, and policy name. - 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:
- Resolve
CommunityPolicyfrom schema. - Build a
suppressed_signals: HashSet<SignalTypeId>from the read rules. - In the scoring loop, skip any signal contribution whose
SignalTypeIdis insuppressed_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_signalsmust 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:
/// 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
SchemaBuilder::community_policy()accepts aCommunityPolicyand validates it atbuild()time.TidalDb::signal_with_community_policy()enforces write rules and returns typedPolicyViolationon rejection.Retrieve::builder().community()threadsCommunityContextto the executor, which suppresses denied-read signals from scoring.- All schema validation errors surface correctly (signal not in schema, conflict, duplicate name).
- All 9 test matrix scenarios covered by integration tests in
tidal/tests/m10_community_policy.rs. - Zero performance regression on the
retrieve()hot path without a community context (no-op fast path). cargo test --liband integration tests pass;clippy -D warningsclean.