# m10p1: Community Governance Policy Engine ## Delivers The versioned, schema-declared governance policy layer that lets a community decide *which* signal intents may influence ranking, *within what weighting bounds*, and *above what trust/quality threshold* -- and makes the governing policy version visible in every query result for explainability. After this phase a community can declare a `GovernancePolicy` in the schema, register successive versions with monotonic version numbers and effective timestamps, and out-of-policy community-scoped signals are rejected or quarantined at the write path instead of silently contaminating the community aggregate. The runtime threads the active `policy_id`/`policy_version` from scoring into `Results.policy_metadata`, so callers can attribute a ranked feed to the exact governance version that produced it. This is the "rules first" phase of M10: no agent capability enforcement yet (M10p2), no provenance graph or remove-by-scope (M10p3). It builds the policy *registry and contract* on top of the M9 scope/share primitives, which are treated as GIVEN. Deliverables: - `GovernancePolicy { community_id, version, effective_at_ns, allowed_intents, excluded_intents, weighting_bounds, trust_threshold }` in `governance/policy.rs` - `WeightingBounds { min_weight, max_weight, per_intent }` newtype with clamp + in-bounds checks - `GovernanceRegistry`: versioned `BTreeMap` per `CommunityId` (mirrors `ProfileRegistry` name->version->profile structure and monotonic-version validation) - `SchemaBuilder::community_policy(community_id, GovernancePolicy)` declaration, validated at `build()` time (monotonic versions, allowed/excluded disjoint, bounds well-formed) - `policy_version` threaded from scoring into `Results.policy_metadata` and `QueryStats` - Out-of-policy community-scoped signal handling: reject (`TidalError`) or route to a quarantine ledger, enforced synchronously in `signal_with_context` / `try_cohort_attribution` - All existing M0-M9 tests pass unchanged (no community policy declared = no governance enforcement; `policy_metadata` is `None`) ## Dependencies - **Requires:** M9 complete -- `SignalScope`, `CommunityId`, `SignalProvenance`, `Membership`/`MembershipEpoch`, `SharePolicy` (all in `tidal/src/governance/`), WAL V3 event envelope carrying `scope`, and the community signal dispatch path in `db/signals.rs`. These M9 primitives are GIVEN and are extended here, never redefined. - **Files modified:** - `tidal/src/governance/mod.rs` -- export `policy::{GovernancePolicy, WeightingBounds, GovernanceRegistry, GovernanceError, PolicyMetadata, IntentDisposition}` - `tidal/src/schema/validation/builders.rs` -- `SchemaBuilder::community_policy`, a `gov_policies: Vec` field, and policy validation in `build()` - `tidal/src/schema/validation/mod.rs` -- `Schema` carries `governance: HashMap>`; accessor `governance_policies()` - `tidal/src/schema/error.rs` -- `SchemaError` governance variants (non-monotonic version, intent in both lists, bad bounds, duplicate effective timestamp) - `tidal/src/ranking/executor/mod.rs` -- `score_inner` accepts an optional active `PolicyMetadata` and stamps it onto results (no scoring math change) - `tidal/src/query/retrieve/types.rs` -- `Results.policy_metadata: Option` - `tidal/src/query/stats.rs` -- `QueryStats.policy_version: Option` governance telemetry - `tidal/src/db/signals.rs` -- out-of-policy gate in `signal_with_context` (community scope) and `try_cohort_attribution` - `tidal/src/db/open.rs` -- load community governance policies into a `GovernanceRegistry` after `with_schema`, store on `TidalDb` - `tidal/src/db/mod.rs` -- `governance_registry: Arc` field + quarantine ledger handle - **Files created:** - `tidal/src/governance/policy.rs` -- `GovernancePolicy`, `WeightingBounds`, `GovernanceRegistry`, `GovernanceError`, `PolicyMetadata`, `IntentDisposition` ## Research References - `docs/research/tidaldb_ranking.md` -- ranking profile versioning model (the `ProfileRegistry` name->version->profile pattern this registry mirrors) - `/tmp/m9m10_brief.md` sections 1.6, 2 (M10p1 row), 4, 5 -- canonical primitive shapes and invariants - `tidal/src/ranking/registry.rs:92,187` -- `ProfileRegistry` versioned `BTreeMap` + monotonic-version validation (direct structural precedent) - `thoughts.md` -- versioned schema artifacts and effective-timestamp semantics ## Acceptance Criteria (Phase Level) - [ ] `GovernancePolicy` declares `allowed_intents: Vec`, `excluded_intents: Vec`, and `weighting_bounds: WeightingBounds` (min/max global + optional per-intent overrides); `WeightingBounds::clamp(intent, w)` returns the in-bounds weight and `in_bounds(intent, w)` is a pure predicate - [ ] `GovernancePolicy` carries `version: u32` and `effective_at_ns: u64`; `GovernanceRegistry::register` rejects a version `<=` the current max for that `CommunityId` with `GovernanceError::VersionConflict` and rejects an `effective_at_ns` not strictly greater than the prior version's - [ ] `GovernanceRegistry::active_at(community_id, now_ns)` returns the highest-version policy whose `effective_at_ns <= now_ns` (or `None`), in O(log n) over the per-community `BTreeMap` - [ ] `SchemaBuilder::community_policy(community_id, policy)` declares a policy; `build()` validates monotonic versions, disjoint allowed/excluded intent sets, and well-formed bounds (`min_weight <= max_weight`, finite), returning a `SchemaError` on violation - [ ] A RETRIEVE query against a community-scoped profile returns `Results.policy_metadata = Some(PolicyMetadata { community_id, policy_version, effective_at_ns })` and `QueryStats.policy_version = Some(version)`; with no governance policy declared both are `None` and all prior tests pass unchanged - [ ] A community-scoped signal whose intent is in `excluded_intents` (or not in a non-empty `allowed_intents`) is rejected with `TidalError` OR routed to the quarantine ledger per `IntentDisposition`, and NEVER reaches the community aggregate -- verified by asserting the community ledger count is unchanged - [ ] `Local`-scope signals are never subject to governance gating (local-profile-intact guarantee): a local signal with a community-disallowed intent still records to the local ledger - [ ] The out-of-policy gate is a synchronous O(1)/O(log n) read on the write path -- no dependency on the 60s sweeper - [ ] Property test: 10,000 random `(intent, weight)` pairs -- `clamp` output is always within `[min, max]` for that intent and `in_bounds` agrees with `clamp(w) == w` - [ ] `cargo clippy -p tidaldb -D warnings` and `cargo fmt` pass; `cargo test -p tidaldb --lib` green ## Task Execution Order ``` Task 01: Policy Types ──────────────┐ (GovernancePolicy, WeightingBounds, │ PolicyMetadata, GovernanceError) │ ├──> Task 03: Schema Declaration Task 02: GovernanceRegistry ─────────┤ (SchemaBuilder::community_policy (versioned BTreeMap, register, │ + build() validation + Schema accessor) active_at, monotonic validation) │ │ │ v │ Task 04: Open-Path Wiring │ (db/open.rs load registry, │ db/mod.rs field + quarantine ledger) │ │ │ v ├──> Task 05: Result Metadata Threading │ (executor -> policy_metadata, │ QueryStats.policy_version) │ │ │ v └──> Task 06: Write-Path Enforcement (signal_with_context + try_cohort_attribution gate, quarantine routing) ``` Tasks 01 and 02 are parallelizable (02 depends only on the 01 types). Task 03 depends on 01+02. Task 04 depends on 03. Task 05 depends on 01 (the metadata type) and 04 (the registry on `TidalDb`). Task 06 depends on 04 (registry + quarantine ledger handles) and is the integration capstone. ## Module Location | File | Status | Contains | |------|--------|----------| | `tidal/src/governance/policy.rs` | NEW | `GovernancePolicy`, `WeightingBounds`, `GovernanceRegistry`, `GovernanceError`, `PolicyMetadata`, `IntentDisposition` | | `tidal/src/governance/mod.rs` | MODIFIED | Re-export policy types alongside M9 scope/provenance/share_policy/membership | | `tidal/src/schema/validation/builders.rs` | MODIFIED | `SchemaBuilder::community_policy`, `gov_policies` field, `build()` validation | | `tidal/src/schema/validation/mod.rs` | MODIFIED | `Schema.governance` map + `governance_policies()` accessor | | `tidal/src/schema/error.rs` | MODIFIED | `SchemaError` governance variants | | `tidal/src/ranking/executor/mod.rs` | MODIFIED | Stamp active `PolicyMetadata` onto scored results (no math change) | | `tidal/src/query/retrieve/types.rs` | MODIFIED | `Results.policy_metadata: Option` | | `tidal/src/query/stats.rs` | MODIFIED | `QueryStats.policy_version: Option` | | `tidal/src/db/signals.rs` | MODIFIED | Out-of-policy community-scope gate + quarantine routing | | `tidal/src/db/open.rs` | MODIFIED | Build `GovernanceRegistry` from schema after `with_schema` | | `tidal/src/db/mod.rs` | MODIFIED | `governance_registry` + quarantine ledger fields on `TidalDb` | ## Technical Design All types live in `tidal/src/governance/policy.rs`. `CommunityId` and `SignalScope` are imported from the M9 `governance::scope` module -- not redefined here. ### WeightingBounds ```rust // tidal/src/governance/policy.rs use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::governance::scope::CommunityId; /// Per-intent weighting constraints a governance policy enforces on /// community-scoped signals. /// /// `min_weight`/`max_weight` are the global bounds; `per_intent` overrides /// them for named intents (e.g. clamp `low_quality` harder than `share`). /// All weights are signal contribution weights in the same space the ranking /// engine consumes; bounds are inclusive and must be finite with /// `min_weight <= max_weight`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WeightingBounds { pub min_weight: f64, pub max_weight: f64, /// Intent name -> (min, max) override. Falls back to the global bounds. pub per_intent: BTreeMap, } impl WeightingBounds { /// Permissive default: `[0.0, 1.0]`, no per-intent overrides. #[must_use] pub fn unbounded_unit() -> Self { Self { min_weight: 0.0, max_weight: 1.0, per_intent: BTreeMap::new() } } /// Resolve the inclusive `(min, max)` bound for `intent`. #[must_use] pub fn bounds_for(&self, intent: &str) -> (f64, f64) { self.per_intent .get(intent) .copied() .unwrap_or((self.min_weight, self.max_weight)) } /// Clamp `weight` into the bounds for `intent`. Pure; total ordering safe /// because policy bounds are validated finite at schema build. #[must_use] pub fn clamp(&self, intent: &str, weight: f64) -> f64 { let (lo, hi) = self.bounds_for(intent); weight.max(lo).min(hi) } /// Whether `weight` already lies within the bounds for `intent`. #[must_use] pub fn in_bounds(&self, intent: &str, weight: f64) -> bool { let (lo, hi) = self.bounds_for(intent); (lo..=hi).contains(&weight) } /// Validate well-formedness: finite, `min <= max`, every override finite /// and ordered. pub(crate) fn validate(&self) -> Result<(), GovernanceError> { if !self.min_weight.is_finite() || !self.max_weight.is_finite() || self.min_weight > self.max_weight { return Err(GovernanceError::BadBounds { min: self.min_weight, max: self.max_weight, }); } for (intent, (lo, hi)) in &self.per_intent { if !lo.is_finite() || !hi.is_finite() || lo > hi { return Err(GovernanceError::BadIntentBounds { intent: intent.clone(), min: *lo, max: *hi, }); } } Ok(()) } } ``` ### GovernancePolicy ```rust // tidal/src/governance/policy.rs /// How a community treats a signal whose intent is outside the policy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum IntentDisposition { /// Reject the write with a `TidalError` (loud, default). #[default] Reject, /// Record into the quarantine ledger instead of the community aggregate. Quarantine, } /// A versioned community governance policy. /// /// Declared in the schema via [`SchemaBuilder::community_policy`] and resolved /// at runtime through the [`GovernanceRegistry`]. A policy governs which signal /// intents may influence the community aggregate (`allowed_intents` / /// `excluded_intents`), the weighting bounds applied to admitted signals /// (`weighting_bounds`), and the minimum trust/quality threshold a contributor /// must meet (`trust_threshold`). Versions are monotonic per `community_id`; /// `effective_at_ns` selects the active version for a given query time. /// /// Only community-scoped signals are governed. `SignalScope::Local` events are /// never subject to this policy -- the local-profile-intact guarantee. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct GovernancePolicy { pub community_id: CommunityId, /// Monotonic version, strictly increasing per `community_id`. pub version: u32, /// Wall-clock nanoseconds at which this version becomes active. pub effective_at_ns: u64, /// If non-empty, only these intents may influence the community aggregate. /// Empty means "all intents not in `excluded_intents`". pub allowed_intents: Vec, /// Intents always excluded, regardless of `allowed_intents`. pub excluded_intents: Vec, /// Weighting constraints applied to admitted signals. pub weighting_bounds: WeightingBounds, /// Minimum contributor trust/quality score in `[0.0, 1.0]`; `0.0` admits all. pub trust_threshold: f64, /// Disposition for out-of-policy intents. pub disposition: IntentDisposition, } impl GovernancePolicy { /// Whether `intent` is admitted by this policy's allow/exclude lists. /// /// Excluded always loses; an empty `allowed_intents` admits everything not /// excluded; a non-empty `allowed_intents` requires membership. #[must_use] pub fn admits_intent(&self, intent: &str) -> bool { if self.excluded_intents.iter().any(|i| i == intent) { return false; } self.allowed_intents.is_empty() || self.allowed_intents.iter().any(|i| i == intent) } /// Validate internal consistency (disjoint lists, well-formed bounds, /// trust threshold in range). Called at schema build. pub(crate) fn validate(&self) -> Result<(), GovernanceError> { for intent in &self.allowed_intents { if self.excluded_intents.iter().any(|e| e == intent) { return Err(GovernanceError::IntentInBothLists { intent: intent.clone(), }); } } if !(0.0..=1.0).contains(&self.trust_threshold) { return Err(GovernanceError::BadTrustThreshold(self.trust_threshold)); } self.weighting_bounds.validate() } } ``` ### GovernanceRegistry Mirrors `ProfileRegistry` (`tidal/src/ranking/registry.rs:92`): a per-key versioned `BTreeMap` with monotonic-version validation on `register`. ```rust // tidal/src/governance/policy.rs /// Versioned registry of community governance policies. /// /// Stores `community_id -> version -> policy`. `register` enforces monotonic /// versions and strictly increasing `effective_at_ns` per community. /// `active_at` selects the highest version whose `effective_at_ns <= now_ns`. /// /// Mirrors `ProfileRegistry`'s structure so governance versioning behaves /// identically to ranking-profile versioning. #[derive(Debug, Default)] pub struct GovernanceRegistry { /// community_id -> version -> policy policies: std::collections::HashMap>, } impl GovernanceRegistry { #[must_use] pub fn new() -> Self { Self { policies: std::collections::HashMap::new() } } /// Register a policy version. /// /// # Errors /// - `VersionConflict` if `version <= max` for this community /// - `NonMonotonicEffective` if `effective_at_ns <=` the prior version's /// - any [`GovernancePolicy::validate`] error pub fn register(&mut self, policy: GovernancePolicy) -> Result<(), GovernanceError> { policy.validate()?; let versions = self.policies.entry(policy.community_id).or_default(); if let Some((&max_v, prev)) = versions.last_key_value() { if policy.version <= max_v { return Err(GovernanceError::VersionConflict { community_id: policy.community_id, existing: max_v, new: policy.version, }); } if policy.effective_at_ns <= prev.effective_at_ns { return Err(GovernanceError::NonMonotonicEffective { community_id: policy.community_id, prev_effective_ns: prev.effective_at_ns, new_effective_ns: policy.effective_at_ns, }); } } versions.insert(policy.version, policy); Ok(()) } /// The policy active for `community_id` at `now_ns`: highest version whose /// `effective_at_ns <= now_ns`. O(log n) over the per-community map. #[must_use] pub fn active_at(&self, community_id: CommunityId, now_ns: u64) -> Option<&GovernancePolicy> { self.policies .get(&community_id)? .values() .rev() .find(|p| p.effective_at_ns <= now_ns) } /// The latest registered version for a community, ignoring effective time. #[must_use] pub fn latest(&self, community_id: CommunityId) -> Option<&GovernancePolicy> { self.policies.get(&community_id)?.last_key_value().map(|(_, p)| p) } /// Whether any community has a policy declared. #[must_use] pub fn is_empty(&self) -> bool { self.policies.is_empty() } } ``` ### PolicyMetadata (result threading) ```rust // tidal/src/governance/policy.rs /// Governing-policy attribution attached to a query result for explainability. /// /// Threaded from the active `GovernancePolicy` through the ranking executor /// into `Results.policy_metadata`. `None` on results from a community with no /// governance policy declared. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct PolicyMetadata { pub community_id: CommunityId, pub policy_version: u32, pub effective_at_ns: u64, } impl From<&GovernancePolicy> for PolicyMetadata { fn from(p: &GovernancePolicy) -> Self { Self { community_id: p.community_id, policy_version: p.version, effective_at_ns: p.effective_at_ns, } } } ``` ### GovernanceError ```rust // tidal/src/governance/policy.rs /// Errors from governance policy registration and validation. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum GovernanceError { #[error("community {community_id}: version {new} <= existing max {existing}")] VersionConflict { community_id: CommunityId, existing: u32, new: u32 }, #[error( "community {community_id}: effective_at {new_effective_ns} <= prior {prev_effective_ns}" )] NonMonotonicEffective { community_id: CommunityId, prev_effective_ns: u64, new_effective_ns: u64, }, #[error("intent '{intent}' appears in both allowed and excluded lists")] IntentInBothLists { intent: String }, #[error("invalid weighting bounds: min {min} > max {max} or non-finite")] BadBounds { min: f64, max: f64 }, #[error("intent '{intent}': invalid bounds min {min} > max {max} or non-finite")] BadIntentBounds { intent: String, min: f64, max: f64 }, #[error("trust_threshold {0} out of range [0.0, 1.0]")] BadTrustThreshold(f64), } ``` ### Schema declaration `SchemaBuilder` gains a `gov_policies` vector and a `community_policy` method that mirrors `session_policy` (`builders.rs:103`). At `build()`, governance policies are folded into a `GovernanceRegistry`-shaped map on the `Schema`; registry-level monotonic checks reuse `GovernanceRegistry::register` so the build path and runtime path validate identically. `SchemaError` gains `#[from]`-style governance variants surfacing `GovernanceError`. ```rust // tidal/src/schema/validation/builders.rs (added) /// Declare a versioned governance policy for a community. /// /// Validated at `build()`: monotonic versions per community, disjoint /// allowed/excluded intents, and well-formed weighting bounds. Declare after /// signal declarations so intent names can be cross-checked where applicable. pub fn community_policy( &mut self, community_id: CommunityId, policy: GovernancePolicy, ) -> &mut Self { self.gov_policies.push(GovPolicyEntry { community_id, policy }); self } ``` ```rust // tidal/src/schema/validation/mod.rs (Schema gains a field + accessor) impl Schema { /// All governance policies, grouped `community_id -> version -> policy`. #[must_use] pub fn governance_policies( &self, ) -> &HashMap> { &self.governance } } ``` ### Open-path wiring ```rust // tidal/src/db/open.rs (in open_with_schema, after with_schema) let mut governance_registry = GovernanceRegistry::new(); for versions in schema.governance_policies().values() { for policy in versions.values() { governance_registry.register(policy.clone()).map_err(|e| { TidalError::internal("open", format!("invalid governance policy: {e}")) })?; } } // Stored as Arc on TidalDb alongside profile_registry. ``` ### Result metadata threading The executor's `score_inner` does not change scoring math. The active `PolicyMetadata` (resolved by the query layer via `GovernanceRegistry::active_at(community_id, now)` for community-scoped queries) is passed through and stamped onto the `Results`: ```rust // tidal/src/query/retrieve/types.rs (Results gains a field) pub struct Results { // ... existing fields ... /// Governing community policy version, for explainability. `None` when the /// query is not community-scoped or no policy is declared. pub policy_metadata: Option, } // tidal/src/query/stats.rs (QueryStats gains a field) pub struct QueryStats { // ... existing fields ... /// Governing policy version applied to this query, if any. pub policy_version: Option, } ``` ### Write-path enforcement The out-of-policy gate is added to `signal_with_context` (community scope) and `try_cohort_attribution` (`db/signals.rs:219,280`). It is a synchronous read of the active policy -- O(log n) registry lookup, no sweeper. ```rust // tidal/src/db/signals.rs (sketch, inside the community-scope branch) // Local scope is NEVER governed -- local-profile-intact guarantee. if scope.is_community() { if let Some(policy) = self .governance_registry .active_at(scope.community_id(), timestamp.as_nanos()) { if !policy.admits_intent(signal_type) { match policy.disposition { IntentDisposition::Reject => { return Err(TidalError::policy_rejected(signal_type)); } IntentDisposition::Quarantine => { self.quarantine_ledger.record(/* ... */); return Ok(()); // never reaches the community aggregate } } } // Admitted: clamp the weight into policy bounds before community record. weight = policy.weighting_bounds.clamp(signal_type, weight); } } ``` ## Notes ### Local-profile-intact guarantee (load-bearing) Governance gating runs ONLY for `SignalScope::Community`. A local-scope signal with a community-disallowed intent must still record to the local ledger unchanged. A routing bug that applies governance to local scope is silent data loss on the user's own profile -- every test in this phase asserts the local ledger is untouched by governance rejection/quarantine. (Brief sections 1.1, 4.5.) ### <1s enforcement is synchronous, never the sweeper The out-of-policy gate and the active-policy lookup are O(log n) reads on the write path. They must NOT route through the 60s sweeper (`db/sweeper.rs:14`). Policy enforcement is immediate by construction. (Brief section 4.4.) ### Mirror ProfileRegistry, do not reinvent versioning `GovernanceRegistry` deliberately copies `ProfileRegistry`'s `HashMap>` shape and monotonic-version rule (`registry.rs:92,187`). Effective-timestamp ordering is the one addition: registration requires both `version` and `effective_at_ns` to strictly increase, so `active_at` is an unambiguous reverse scan. Do not introduce a parallel versioning scheme. ### M9 primitives are GIVEN -- never redefine `CommunityId`, `SignalScope`, `SharePolicy`, `SignalProvenance`, `Membership` are defined canonically in M9p1 (`tidal/src/governance/{scope,share_policy, provenance,membership}.rs`). This phase imports them. `GovernancePolicy` is the only new primitive; if its shape disagrees with the brief's section 1.6, the brief wins. (Brief section 1.) ### Backward compatibility No WAL/checkpoint format change in this phase -- `policy_version` is derived at query time from the in-memory registry, not persisted per event (provenance persistence is M10p3). A database with no `community_policy` declarations behaves identically to M9: `policy_metadata`/`policy_version` are `None`, governance gating is a no-op, and the `GovernanceRegistry` is empty. The new `Results.policy_metadata`/`QueryStats.policy_version` fields are `Option`, so existing construction sites set `None`. (Brief section 4.1.) ### Quarantine ledger When `disposition = Quarantine`, the out-of-policy signal is recorded to a separate quarantine ledger (handle on `TidalDb`), never the community aggregate, so it is inspectable but does not influence ranking. The default disposition is `Reject` (loud). ## Done When A developer can declare two versions of a `GovernancePolicy` for a community in the schema (v2 with a later `effective_at_ns` than v1), open the database, write a community-scoped signal whose intent is allowed (it lands in the community aggregate with its weight clamped into bounds) and one whose intent is excluded (it is rejected or quarantined and never reaches the aggregate), write the same excluded intent at `Local` scope and confirm the local ledger records it untouched, then run a community-scoped RETRIEVE and read back `Results.policy_metadata.policy_version` / `QueryStats.policy_version` equal to the version active at query time -- all while every existing M0-M9 test passes unchanged.