- Eliminate the tidal/ self-contained doc mirror; docs now have two canonical homes (root *.md and docs/), with planning/specs/research/reviews moved up - Remove stale .agents/skills and .ai mirrors; canonicalize skills under .claude/ - Add pre-commit hook + scripts/check-docs.sh doc-guard + scripts/install-hooks.sh - Implement M0-M10 seven-dimension review findings across engine, net, server, and tidalctl (durability, replication, query, WAL, storage, CLI hardening)
597 lines
27 KiB
Markdown
597 lines
27 KiB
Markdown
# 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<u32, GovernancePolicy>` 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<GovPolicyEntry>` field, and policy validation in `build()`
|
|
- `tidal/src/schema/validation/mod.rs` -- `Schema` carries `governance: HashMap<CommunityId, BTreeMap<u32, GovernancePolicy>>`; 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<PolicyMetadata>`
|
|
- `tidal/src/query/stats.rs` -- `QueryStats.policy_version: Option<u32>` 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<GovernanceRegistry>` 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<String>`, `excluded_intents: Vec<String>`, 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<PolicyMetadata>` |
|
|
| `tidal/src/query/stats.rs` | MODIFIED | `QueryStats.policy_version: Option<u32>` |
|
|
| `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<String, (f64, f64)>,
|
|
}
|
|
|
|
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<String>,
|
|
/// Intents always excluded, regardless of `allowed_intents`.
|
|
pub excluded_intents: Vec<String>,
|
|
/// 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<CommunityId, BTreeMap<u32, GovernancePolicy>>,
|
|
}
|
|
|
|
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<CommunityId, BTreeMap<u32, GovernancePolicy>> {
|
|
&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<GovernanceRegistry> 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<crate::governance::policy::PolicyMetadata>,
|
|
}
|
|
|
|
// 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<u32>,
|
|
}
|
|
```
|
|
|
|
### 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<key,
|
|
BTreeMap<u32, _>>` 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.
|