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

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 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:

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:

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:

  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:

/// 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.