tidaldb/tidal/docs/planning/milestone-9/phase-1/OVERVIEW.md
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build
- Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference
- Add docker standalone/cluster/deploy images, compose, and prometheus config
- Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry
- Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites
- Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
2026-06-07 18:29:38 -06:00

534 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# m9p1: Signal Scope and Share Contract
## Delivers
The shared governance atoms that every subsequent M9/M10 phase extends rather
than redefines, plus the WAL V3 wire format that carries scope and share
metadata per event. After this phase, every signal has an explicit scope
(`Local` by default), the WAL event envelope records who wrote it under which
share policy and membership epoch, and community replication only ships
share-eligible events. This is the "build the atoms right" phase (mirroring
m8p1): a new `tidal/src/governance/` module owns the canonical definitions of
`SignalScope`, `SignalProvenance`, `SharePolicy`, and `CommunityId`; later
phases reference these exact shapes. No community lifecycle yet — that is m9p2.
Deliverables:
- New module root `tidal/src/governance/mod.rs`, wired into `lib.rs` with `pub mod governance;` (parallels `replication`)
- `SignalScope { Local, Community(CommunityId), Session, Agent }` with a stable u8 wire discriminant; `CommunityId(u64)` newtype
- `SignalProvenance { writer_agent, origin_shard, scope, share_policy_version, membership_epoch, hlc }` — the read-side audit anchor
- `SharePolicy { version, allowed_intents, mode }` with `ShareMode { LocalOnly, CommunityShare }` and per-intent filters
- WAL batch format **V3**: `EventRecord` widened from 21B → 32B, adding `scope`, `writer_agent` (interned u16), `share_policy_version`, `membership_epoch`, and a per-event idempotency tag; `FORMAT_VERSION` bumped to 3
- Default-local enforcement: `signal()`/`signal_with_context` default `SignalScope::Local`; the shipper drops non-share-eligible events before counting
- `tidalctl scope-stats` offline subcommand reporting scope distribution and share eligibility over a persisted directory
- All existing M0M8 tests pass unchanged (V1/V2 batches decode as `Local` scope; single-node defaults to local-only)
## Dependencies
- **Requires:** M8 complete (WAL format V2 with `BatchHeader`/`EventRecord` at `tidal/src/wal/format/batch.rs`, `ShardId`/`RegionId` at `tidal/src/replication/shard.rs`, `Hlc`/`HlcTimestamp` at `tidal/src/replication/crdt/hlc.rs`, `AgentId` at `tidal/src/session/types.rs`, `WalShipper`/`SegmentReceiver` at `tidal/src/replication/{shipper,receiver}.rs`, signal ledger `apply_wal_event` at `tidal/src/signals/ledger/core.rs:245`)
- **Files modified:**
- `tidal/src/wal/format/batch.rs``EventRecord` V3 widening (21B → 32B), `FORMAT_VERSION` → 3, `EVENT_SIZE`, `encode_batch_with_shard`, `decode_batch`, `event_content_hash`
- `tidal/src/wal/format/batch_tests.rs` — V3 roundtrip + V1/V2/V3 backward-compat decode + proptest
- `tidal/src/db/signals.rs` — default `SignalScope::Local` in `signal`/`signal_with_context`; thread scope into the write path
- `tidal/src/replication/shipper.rs` — share-eligibility filter before `count_events_in_segment`
- `tidal/src/replication/receiver.rs` — receiver honors scope on `apply_payload`
- `tidal/src/signals/ledger/core.rs``apply_wal_event` threads scope/provenance metadata
- `tidal/src/signals/checkpoint/format.rs` — checkpoint entry V3 (carry scope-aware fields; V1/V2 decode clean)
- `tidalctl/src/main.rs``scope-stats` subcommand
- `tidal/src/lib.rs` — add `pub mod governance;`
- **Files created:**
- `tidal/src/governance/mod.rs` — module root, re-exports
- `tidal/src/governance/scope.rs``SignalScope`, `CommunityId`
- `tidal/src/governance/provenance.rs``SignalProvenance`
- `tidal/src/governance/share_policy.rs``SharePolicy`, `ShareMode`, `ShareIntent`
## Research References
- `docs/research/tidaldb_wal.md` — WAL segment format, batch header layout, BLAKE3 checksum, dedup window
- `docs/planning/milestone-8/phase-1/OVERVIEW.md` — the m8p1 "atoms right" template this phase mirrors; WAL V2 byte layout (shard/region at bytes 28-31 of the header)
- `thoughts.md` — Part V.12 (subject-prefix key encoding); Part II.1 (WAL convergence / replay determinism)
- `ai-lookup/services/signals.md` — signal intents (`not_for_me`, `low_quality`, `hide/mute/block`, `skip_for_now`)
- M9→M10 architecture brief — section 1 (canonical shared primitives), section 4 (WAL compat + local-profile-intact invariants), section 5 (test strategy)
## Acceptance Criteria (Phase Level)
- [ ] `SignalScope` is `Copy + Clone + Debug + PartialEq + Eq + Hash + Serialize + Deserialize` with a stable `as_u8()`/`from_u8()` round-trip: `Local`=0, `Community`=1, `Session`=2, `Agent`=3; unknown byte → `WalError::Corruption`
- [ ] `SignalScope::default()` returns `SignalScope::Local` (the local-profile-intact default)
- [ ] `CommunityId(u64)` newtype derives `Copy + Clone + Debug + Eq + Hash + Ord + Serialize + Deserialize` with `Display` producing `"c{n}"`
- [ ] `SignalProvenance` carries `writer_agent`, `origin_shard: ShardId`, `scope: SignalScope`, `share_policy_version: u16`, `membership_epoch: u32`, `hlc: HlcTimestamp`; derives `Clone + Debug + PartialEq + Serialize + Deserialize`
- [ ] `SharePolicy { version: u16, allowed_intents: BTreeSet<ShareIntent>, mode: ShareMode }`; `ShareMode { LocalOnly (default), CommunityShare }`; `SharePolicy::local_only()` constructor produces a policy that ships nothing
- [ ] `SharePolicy::is_share_eligible(scope, intent) -> bool` returns `false` for any `SignalScope::Local` event regardless of policy (load-bearing local-never-ships rule), and `true` only when `mode == CommunityShare` AND `intent ∈ allowed_intents`
- [ ] `EventRecord` V3 wire format is exactly 32 bytes: existing `entity_id`(8) + `signal_type`(1) + `weight`(4) + `timestamp_nanos`(8) = 21B, plus `scope`(1) + `writer_agent`(2) + `share_policy_version`(2) + `membership_epoch`(4) = 30B, plus 2B reserved/idempotency-tag padding to a clean 32B
- [ ] `FORMAT_VERSION` bumped to 3 (`FORMAT_VERSION_V3 = 3`); `encode_batch_with_shard` writes V3 events; `decode_batch` accepts V1, V2, and V3
- [ ] V1/V2 batches decode under V3 code with `scope = Local`, `writer_agent = 0`, `share_policy_version = 0`, `membership_epoch = 0` (zero-fill new fields) — verified by explicit per-version roundtrip tests in `batch_tests.rs`
- [ ] `event_content_hash` incorporates the new V3 fields so dedup never false-positives across scopes, AND a V1/V2 event hashes identically under V3 decode (back-compat dedup window unbroken)
- [ ] `signal()` and `signal_with_context()` write `SignalScope::Local` by default; a local signal written then queried on a follower's ledger is NOT present (shipper drops it)
- [ ] Shipper filter drops every `SignalScope::Local` event and every non-share-eligible community event before shipping; share-eligible community events ship intact
- [ ] Per-intent filter: a `low_quality` community signal ships when `low_quality ∈ allowed_intents`; a `view` signal does NOT ship when `view ∉ allowed_intents` — asserted on the receiver ledger
- [ ] `tidalctl scope-stats --path <dir>` performs an offline read-only WAL scan and emits JSON with per-scope event counts and a `share_eligible` count; exit code 0 on a seeded directory
- [ ] All existing M0M8 lib + integration tests pass without modification; `cargo fmt` clean; `cargo clippy -p tidaldb -D warnings` clean
## Task Execution Order
```
Task 01: Governance atoms (scope, provenance) ──┐
├──> Task 03: EventRecord V3 wire format
Task 02: SharePolicy + ShareIntent ─────────────┤ │
│ ├──> Task 04: Write-path default-local
│ │ │
│ │ ├──> Task 05: Shipper share filter
│ │ │
│ └──────────┤
│ v
└──> Task 06: Checkpoint V3 + ledger metadata
v
Task 07: tidalctl scope-stats
```
Tasks 01 and 02 are fully parallelizable pure-type work (they unblock everything; do them first). Task 03 (the highest-blast-radius change) depends on Task 01. Task 04 (write path) depends on 01+03. Task 05 (shipper) depends on 02+04. Task 06 (checkpoint/ledger) depends on 01+03. Task 07 (CLI) depends on 03 (it scans the V3 wire format).
## Module Location
| File | Status | Contains |
|------|--------|----------|
| `tidal/src/governance/mod.rs` | NEW | Module root, re-exports of all governance atoms |
| `tidal/src/governance/scope.rs` | NEW | `SignalScope`, `CommunityId`, u8 wire codec |
| `tidal/src/governance/provenance.rs` | NEW | `SignalProvenance` (read-side audit anchor) |
| `tidal/src/governance/share_policy.rs` | NEW | `SharePolicy`, `ShareMode`, `ShareIntent`, eligibility logic |
| `tidal/src/wal/format/batch.rs` | MODIFIED | `EventRecord` V3 (32B), `FORMAT_VERSION_V3`, encode/decode/dedup |
| `tidal/src/wal/format/batch_tests.rs` | MODIFIED | V3 roundtrip + V1/V2/V3 back-compat + proptest |
| `tidal/src/db/signals.rs` | MODIFIED | Default `SignalScope::Local`; thread scope into write path |
| `tidal/src/replication/shipper.rs` | MODIFIED | Share-eligibility filter before ship |
| `tidal/src/replication/receiver.rs` | MODIFIED | Scope-aware `apply_payload` |
| `tidal/src/signals/ledger/core.rs` | MODIFIED | `apply_wal_event` threads scope/provenance |
| `tidal/src/signals/checkpoint/format.rs` | MODIFIED | Checkpoint entry V3 (scope-aware; V1/V2 decode clean) |
| `tidalctl/src/main.rs` | MODIFIED | `scope-stats` offline subcommand |
| `tidal/src/lib.rs` | MODIFIED | `pub mod governance;` |
## Technical Design
### SignalScope + CommunityId — `tidal/src/governance/scope.rs`
```rust
use std::fmt;
use crate::wal::error::WalError;
/// Identifies a community personalization overlay.
///
/// `CommunityId(0)` is reserved (no community). Communities are opt-in overlays
/// on top of a user's local profile; see `SignalScope::Community`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord,
serde::Serialize, serde::Deserialize)]
pub struct CommunityId(pub u64);
impl CommunityId {
/// Sentinel meaning "no community".
pub const NONE: Self = Self(0);
/// Return the underlying `u64`.
#[must_use]
pub const fn as_u64(self) -> u64 {
self.0
}
}
impl fmt::Display for CommunityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "c{}", self.0)
}
}
/// The scope of a signal: where it lives and whether it may ever ship.
///
/// `Local` is the default and the single most load-bearing invariant of M9:
/// local-scope signals NEVER ship to a community and are NEVER purged by a
/// community operation. The wire discriminant is stable across versions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default,
serde::Serialize, serde::Deserialize)]
pub enum SignalScope {
/// On-device personalization. Never shipped, never community-purged.
#[default]
Local,
/// Contributed to a community overlay (subject to `SharePolicy`).
Community(CommunityId),
/// Bound to a single session's lifetime.
Session,
/// Written by an agent on the user's behalf.
Agent,
}
impl SignalScope {
/// Stable wire discriminant. The `Community` payload is encoded separately
/// (the discriminant alone occupies one byte in the WAL event record).
#[must_use]
pub const fn discriminant(self) -> u8 {
match self {
SignalScope::Local => 0,
SignalScope::Community(_) => 1,
SignalScope::Session => 2,
SignalScope::Agent => 3,
}
}
/// Reconstruct a scope from its wire discriminant. `Community` is rebuilt
/// with `CommunityId::NONE`; the caller overlays the real id from the
/// membership-epoch context (the community id is not stored inline in the
/// per-event record — `membership_epoch` keys it).
///
/// # Errors
///
/// Returns `WalError::Corruption` for any discriminant outside `0..=3`.
pub fn from_discriminant(b: u8) -> Result<Self, WalError> {
match b {
0 => Ok(SignalScope::Local),
1 => Ok(SignalScope::Community(CommunityId::NONE)),
2 => Ok(SignalScope::Session),
3 => Ok(SignalScope::Agent),
other => Err(WalError::Corruption {
message: format!("invalid SignalScope discriminant: {other}"),
}),
}
}
/// Whether this scope may ever leave the local node. `Local` never ships.
#[must_use]
pub const fn is_shippable(self) -> bool {
!matches!(self, SignalScope::Local)
}
}
```
### SignalProvenance — `tidal/src/governance/provenance.rs`
```rust
use crate::governance::scope::SignalScope;
use crate::replication::{crdt::hlc::HlcTimestamp, shard::ShardId};
/// The audit anchor for a signal: who wrote it, in what scope, under which
/// share-policy version and membership epoch, ordered by HLC.
///
/// `writer_agent` is an interned agent id (0 = no agent / direct user write);
/// the intern table maps `AgentId` <-> `u16`. M10p3 builds the provenance graph
/// and explainability surface on top of this struct.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SignalProvenance {
/// Interned writer agent id; 0 means a direct (non-agent) user write.
pub writer_agent: u16,
/// Shard that produced the originating WAL event.
pub origin_shard: ShardId,
/// Scope under which the signal was written.
pub scope: SignalScope,
/// Share-policy version in effect at write time.
pub share_policy_version: u16,
/// Membership epoch in effect at write time (0 = local / no membership).
pub membership_epoch: u32,
/// Hybrid logical clock stamp for deterministic ordering on replay.
pub hlc: HlcTimestamp,
}
impl SignalProvenance {
/// Provenance for a default local write (no agent, no community).
#[must_use]
pub fn local(origin_shard: ShardId, hlc: HlcTimestamp) -> Self {
Self {
writer_agent: 0,
origin_shard,
scope: SignalScope::Local,
share_policy_version: 0,
membership_epoch: 0,
hlc,
}
}
}
```
### SharePolicy + ShareIntent — `tidal/src/governance/share_policy.rs`
```rust
use std::collections::BTreeSet;
use crate::governance::scope::SignalScope;
/// A signal intent that may be selectively allowed to ship to a community.
///
/// Mirrors the engagement-feedback vocabulary surfaced in
/// `ai-lookup/services/signals.md`. New intents append to the enum; the
/// `BTreeSet` ordering keeps `allowed_intents` serialization deterministic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord,
serde::Serialize, serde::Deserialize)]
pub enum ShareIntent {
SkipForNow,
NotForMe,
LowQuality,
Hide,
Mute,
Block,
Save,
View,
Like,
}
/// Whether a share policy keeps everything local or contributes to a community.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default,
serde::Serialize, serde::Deserialize)]
pub enum ShareMode {
/// Nothing ships; the local profile is the only sink.
#[default]
LocalOnly,
/// Share-eligible intents contribute to a community overlay.
CommunityShare,
}
/// Versioned per-user share contract: which intents may ship and in what mode.
///
/// The canonical definition referenced by m9p2 (membership) and m10p1
/// (governance policy). Versions are monotonic; the in-effect version is
/// stamped into every shipped `EventRecord` and `SignalProvenance`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SharePolicy {
/// Monotonic version; stamped into provenance and the WAL event.
pub version: u16,
/// Intents the user has opted to share. Empty in `LocalOnly`.
pub allowed_intents: BTreeSet<ShareIntent>,
/// Overall sharing mode.
pub mode: ShareMode,
}
impl SharePolicy {
/// A policy that ships nothing — the secure default.
#[must_use]
pub fn local_only() -> Self {
Self {
version: 0,
allowed_intents: BTreeSet::new(),
mode: ShareMode::LocalOnly,
}
}
/// Whether an event of `scope` carrying `intent` is eligible to ship.
///
/// `Local` scope is NEVER eligible regardless of policy (the
/// local-profile-intact guarantee). Otherwise eligibility requires
/// `CommunityShare` mode AND the intent in `allowed_intents`.
#[must_use]
pub fn is_share_eligible(&self, scope: SignalScope, intent: ShareIntent) -> bool {
if !scope.is_shippable() {
return false;
}
matches!(self.mode, ShareMode::CommunityShare) && self.allowed_intents.contains(&intent)
}
}
impl Default for SharePolicy {
fn default() -> Self {
Self::local_only()
}
}
```
### EventRecord V3 — `tidal/src/wal/format/batch.rs` (MODIFIED)
```rust
/// Wire format version 3: per-event scope and share metadata.
///
/// Widens `EventRecord` from 21 to 32 bytes, adding `scope` (u8),
/// `writer_agent` (interned u16 LE), `share_policy_version` (u16 LE), and
/// `membership_epoch` (u32 LE), plus 2 reserved bytes. Backward compatible:
/// V1/V2 events (21 bytes) decode with `scope = Local` and zeroed metadata.
/// The version byte at header offset 4 disambiguates event width.
pub const FORMAT_VERSION_V3: u8 = 3;
/// Current wire format version. All new batches are encoded as v3.
pub const FORMAT_VERSION: u8 = FORMAT_VERSION_V3;
/// Size of a V1/V2 event record in bytes.
pub const EVENT_SIZE_V2: usize = 21;
/// Size of a V3 event record in bytes (21 base + 11 governance/padding).
pub const EVENT_SIZE_V3: usize = 32;
/// A single signal event record in wire format.
///
/// V3 adds the governance fields. When decoded from a V1/V2 batch, the
/// governance fields default to a local, agent-less, epoch-0 event.
#[derive(Debug, Clone, PartialEq)]
pub struct EventRecord {
pub entity_id: u64,
pub signal_type: u8,
pub weight: f32,
pub timestamp_nanos: u64,
/// V3: scope discriminant (see `SignalScope::discriminant`).
pub scope: u8,
/// V3: interned writer agent id (0 = direct user write).
pub writer_agent: u16,
/// V3: share-policy version in effect at write time.
pub share_policy_version: u16,
/// V3: membership epoch in effect at write time (0 = local).
pub membership_epoch: u32,
}
impl EventRecord {
/// Serialize this event into the 32-byte V3 wire format.
#[must_use]
pub fn to_bytes_v3(&self) -> [u8; EVENT_SIZE_V3] {
let mut buf = [0u8; EVENT_SIZE_V3];
buf[0..8].copy_from_slice(&self.entity_id.to_le_bytes());
buf[8] = self.signal_type;
buf[9..13].copy_from_slice(&self.weight.to_le_bytes());
buf[13..21].copy_from_slice(&self.timestamp_nanos.to_le_bytes());
buf[21] = self.scope;
buf[22..24].copy_from_slice(&self.writer_agent.to_le_bytes());
buf[24..26].copy_from_slice(&self.share_policy_version.to_le_bytes());
buf[26..30].copy_from_slice(&self.membership_epoch.to_le_bytes());
// buf[30..32] reserved (zeroed)
buf
}
/// Deserialize a V3 (32-byte) or legacy V1/V2 (21-byte) event by width.
///
/// # Errors
///
/// Returns `WalError::Corruption` if the slice is neither 21 nor 32 bytes,
/// or the scope discriminant is invalid.
pub fn from_bytes_sized(bytes: &[u8]) -> Result<Self, WalError> {
match bytes.len() {
EVENT_SIZE_V2 => {
// Legacy: zero-fill governance fields, scope = Local.
let base = Self::decode_base(bytes)?;
Ok(Self { scope: 0, writer_agent: 0, share_policy_version: 0,
membership_epoch: 0, ..base })
}
EVENT_SIZE_V3 => {
let mut e = Self::decode_base(&bytes[0..EVENT_SIZE_V2])?;
e.scope = bytes[21];
// Validate the discriminant on the decode path.
crate::governance::scope::SignalScope::from_discriminant(e.scope)?;
e.writer_agent = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
e.share_policy_version = u16::from_le_bytes(bytes[24..26].try_into().unwrap());
e.membership_epoch = u32::from_le_bytes(bytes[26..30].try_into().unwrap());
Ok(e)
}
n => Err(WalError::Corruption {
message: format!("event record: expected 21 or 32 bytes, got {n}"),
}),
}
}
}
/// Decode-time event width selected by batch `version`.
#[must_use]
pub const fn event_size_for_version(version: u8) -> usize {
if version >= FORMAT_VERSION_V3 { EVENT_SIZE_V3 } else { EVENT_SIZE_V2 }
}
/// Per-event content hash for dedup, computed over the full V3 byte image so
/// that scope/agent/epoch differences never collide, while a V1/V2 event
/// (which canonicalizes to scope=Local, zeroed metadata) hashes identically
/// whether read as 21 or 32 bytes.
#[must_use]
pub fn event_content_hash(event: &EventRecord) -> u128 {
let bytes = event.to_bytes_v3();
let hash = blake3::hash(&bytes);
let hash_bytes: &[u8; 32] = hash.as_bytes();
u128::from_le_bytes(hash_bytes[..16].try_into().expect("32-byte hash"))
}
```
`decode_batch` reads the header `version` byte, selects `event_size_for_version`,
and validates `payload_len == event_count * event_size`. The BLAKE3 batch
checksum (header[0..32] || event_bytes) is unchanged in mechanism but now
covers the wider event payload. `encode_batch_with_shard` writes V3 events via
`to_bytes_v3`.
### Write-path default — `tidal/src/db/signals.rs` (MODIFIED)
```rust
// signal() and signal_with_context() default to SignalScope::Local. The base
// signal stays exactly as today; scope is attached at the ledger/WAL boundary.
//
// record_signal gains a scope-aware sibling; the public `signal()` keeps its
// existing arity and passes SignalScope::Local. A new scoped entry point lands
// in m9p2 (community dispatch); m9p1 only guarantees the default.
let scope = SignalScope::Local; // default; community scope arrives in m9p2
```
### Shipper share filter — `tidal/src/replication/shipper.rs` (MODIFIED)
```rust
// Before count_events_in_segment / send_segment, decode the segment, drop every
// event whose scope is Local (never ships) and every community event that is
// not share-eligible under the active SharePolicy, then re-encode the filtered
// batch. Only share-eligible events leave the node.
fn filter_share_eligible(segment_bytes: &[u8], policy: &SharePolicy) -> Vec<u8> {
// decode_batch -> retain events where policy.is_share_eligible(scope, intent)
// -> encode_batch_with_shard(filtered, ...)
// A segment that filters down to zero events is skipped entirely.
unimplemented!("see task-05")
}
```
## Notes
### Backward compatibility is non-negotiable (brief section 4.1)
`FORMAT_VERSION` → 3. The 64-byte header is already full (shard/region live at
bytes **28-31** in V2 — verified against `batch.rs:193`), so V3 per-event
metadata goes in the EVENT payload by widening `EVENT_SIZE` 21B → 32B, NOT in
the header. The header `version` byte (offset 4) is the sole disambiguator of
event width. V1/V2 batches must decode under V3 code with `scope = Local` and
zeroed governance fields; this is safe because legacy events are 21 bytes and
`from_bytes_sized` branches on length. Explicit V1/V2/V3 roundtrip tests in
`batch_tests.rs` are required, plus a proptest over random V3 batches.
### Dedup window must not break (brief section 4.1)
`event_content_hash` now hashes the 32-byte V3 image. The canonicalization rule
is: a legacy event read as V1/V2 produces the same `EventRecord`
(scope=Local, zeroed metadata) it would produce written as V3, so its content
hash is identical across versions — the dedup window does not false-positive nor
false-negative across a format upgrade. Two community events that differ only in
scope/agent/epoch correctly hash differently.
### Local-profile-intact guarantee (brief section 5, the single load-bearing rule)
`SignalScope::Local` events NEVER ship and are NEVER purged by community
operations. This is enforced in two independent places: `SharePolicy::is_share_eligible`
short-circuits to `false` for local scope, and the shipper filter drops local
events before counting. Every M9 UAT asserts the local feed is unchanged across
join/share/leave/purge. A routing bug that ships or purges local scope is silent
data loss — treat any test that observes a local event on a follower as a
BLOCKER.
### Community id is keyed by epoch, not stored inline
To keep the per-event record at a clean 32 bytes, the `Community(CommunityId)`
payload is NOT serialized inline. The event stores the `scope` discriminant and
the `membership_epoch`; the membership registry (m9p2) maps
`(user, membership_epoch) -> CommunityId`. `from_discriminant` therefore returns
`Community(CommunityId::NONE)` and the caller overlays the real id. This keeps
m9p1 self-contained while leaving the join hook for m9p2.
### <1s gates are NOT in this phase
The synchronous atomic stop-forward / revocation gates are m9p2/m10p2. m9p1
only establishes the scope/share atoms and the WAL envelope. Do not route any
filtering through the 60s sweeper.
### tidalctl is offline and read-only
`scope-stats` opens no DB; it scans WAL segment files directly (the same
`decode_batch` path), tallies per-scope counts and share eligibility, and emits
JSON. Mirrors the existing `status`/`diagnostics` offline-scan pattern in
`tidalctl/src/main.rs`.
## Done When
A developer can write signals that default to `SignalScope::Local`, observe
that local signals never appear on a follower's ledger, opt specific intents
into a `SharePolicy` so only those community events ship, decode a mixed
V1/V2/V3 WAL directory without corruption, and run `tidalctl scope-stats` to
see the per-scope event distribution and share-eligible count. All existing
M0M8 tests pass unchanged, and the WAL V3 roundtrip + backward-compat proptest
is green.