From 5a3dd4884c273c95c805ec7ef00b58b07d25a415 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 1 Nov 2026 14:58:53 -0600 Subject: [PATCH 1/3] feat(id): signed themes and layered board views (#2813) Theme object v1: design tokens, closed layout enum, content-addressed asset refs, fork lineage. Board.theme_ref applies a theme by reference (theme published first; themes name no board, keeping the content-addressed graph acyclic). BoardGraph::compose_view projects the three layers - personal filter, board view, host view - under the transparency rule: hidden objects stay in the graph and are reported with the layer and record that hid them, and the board moderation log is public in every view. Custom HTML templates stay out of v1: a theme is data, never markup. --- pkgs/id/src/boards.rs | 604 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 600 insertions(+), 4 deletions(-) diff --git a/pkgs/id/src/boards.rs b/pkgs/id/src/boards.rs index 59275feca..4817a9bd6 100644 --- a/pkgs/id/src/boards.rs +++ b/pkgs/id/src/boards.rs @@ -4,8 +4,12 @@ //! not a second signature or storage protocol. Each object is immutable; edits //! and deletions add [`BoardObject::Supersede`] and [`BoardObject::Tombstone`] //! records. [`BoardGraph`] checks signatures, canonical references, board scope, -//! and the authority rules needed to accept an object. It does not render a view, -//! enforce moderation, or fetch attachment/game payloads. +//! and the authority rules needed to accept an object. [`BoardObject::Theme`] +//! adds signed, portable themes a board applies by reference +//! ([`Board::theme_ref`]), and [`BoardGraph::compose_view`] projects the +//! three-layer view (personal filter / board view / host view) under the +//! transparency rule: hidden is never erased. It does not render markup, +//! serve content, or fetch attachment/game payloads. use std::collections::{BTreeMap, BTreeSet}; @@ -21,6 +25,10 @@ pub const BOARD_OBJECT_KIND: &str = "board_object.v1"; pub const MAX_BOARD_TEXT_BYTES: usize = 16 * 1024; /// Largest number of content references accepted in one object. pub const MAX_BOARD_REFERENCES: usize = 128; +/// Largest number of design tokens accepted in one theme. +pub const MAX_THEME_TOKENS: usize = 64; +/// Largest design-token value accepted in a theme. +pub const MAX_THEME_TOKEN_VALUE_BYTES: usize = 512; /// A canonical lower-case SHA-256 artifact/content hash. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] @@ -73,6 +81,13 @@ pub struct Board { pub board_id: String, /// Optional source root when this board is a successor fork. pub forked_from: Option, + /// Theme applied by reference, if the board names one. The named + /// [`Theme`] object must already be in the graph when the board root + /// is accepted: publish the theme first, then the board that applies + /// it. Older v1 board bodies written before this field existed decode + /// as `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_ref: Option, } /// Versioned board rules. A revision is a new immutable object. @@ -239,6 +254,45 @@ pub struct Tombstone { pub reason: Option, } +/// Closed set of board layouts a theme may select. Custom HTML templates +/// are explicitly out of v1: a theme is data (tokens, one layout, asset +/// references), never markup or code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeLayout { + /// A single vertical list of threads. + List, + /// A grid of thread cards. + Grid, + /// A dense list with reduced spacing. + Compact, +} + +/// A signed, portable board theme: design tokens, one layout, and +/// content-addressed asset references. +/// +/// A theme names no board. It is a reusable signed package, and a board +/// applies one by reference through [`Board::theme_ref`]. That keeps the +/// content-addressed graph acyclic: a theme that named its board could +/// never be referenced from that board's own root hash. `forked_from` is +/// the fork lineage — the theme this one derives from, if any. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Theme { + /// Human-readable theme name. + pub name: String, + /// Design tokens by name (for example `accent`, `surface`); values + /// are plain data — colors, sizes, font names — never markup. + pub tokens: BTreeMap, + /// Layout selected from the closed v1 set. + pub layout: ThemeLayout, + /// Content-addressed asset blobs (logos, backgrounds); metadata + /// references only, payloads are never embedded. + pub assets: Vec, + /// Theme this one was forked from, if any (fork lineage). + pub forked_from: Option, +} + /// One typed body carried by a [`SignedBoardObject`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] @@ -267,6 +321,8 @@ pub enum BoardObject { Supersede(Supersede), /// Author deletion request. Tombstone(Tombstone), + /// Signed portable theme. + Theme(Theme), } impl BoardObject { @@ -284,12 +340,13 @@ impl BoardObject { Self::BoardFork(_) => "board_fork", Self::Supersede(_) => "supersede", Self::Tombstone(_) => "tombstone", + Self::Theme(_) => "theme", } } fn board_ref(&self) -> Option<&ArtifactHash> { match self { - Self::Board(_) | Self::BoardFork(_) => None, + Self::Board(_) | Self::BoardFork(_) | Self::Theme(_) => None, Self::Rules(value) => Some(&value.board), Self::Forum(value) => Some(&value.board), Self::Thread(value) => Some(&value.board), @@ -362,6 +419,31 @@ impl BoardObject { "duplicate source frontier reference" ); } + Self::Theme(value) => { + validate_text("theme name", &value.name, false)?; + ensure!( + value.tokens.len() <= MAX_THEME_TOKENS, + "a theme names at most {MAX_THEME_TOKENS} tokens" + ); + for (name, token) in &value.tokens { + validate_token_name(name)?; + ensure!( + !token.is_empty() + && token.len() <= MAX_THEME_TOKEN_VALUE_BYTES + && !token.contains('\0'), + "theme token value must be 1 to {MAX_THEME_TOKEN_VALUE_BYTES} bytes without NUL" + ); + } + ensure!( + value.assets.len() <= MAX_BOARD_REFERENCES, + "a theme names at most {MAX_BOARD_REFERENCES} assets" + ); + let unique: BTreeSet<_> = value.assets.iter().collect(); + ensure!( + unique.len() == value.assets.len(), + "duplicate theme asset reference" + ); + } Self::Supersede(value) => ensure!( value.previous != value.replacement, "an object cannot supersede itself" @@ -471,6 +553,16 @@ impl BoardGraph { if let Some(source) = &board.forked_from { self.require_board(source)?; } + if let Some(theme) = &board.theme_ref { + let theme_object = self + .objects + .get(theme) + .context("board theme reference is missing")?; + ensure!( + matches!(theme_object.object, BoardObject::Theme(_)), + "board theme reference is not a theme object" + ); + } } BoardObject::Rules(value) => { self.require_moderator(&value.board, author)?; @@ -601,6 +693,22 @@ impl BoardGraph { } } } + BoardObject::Theme(value) => { + if let Some(source) = &value.forked_from { + ensure!( + source != &signed.hash()?, + "a theme cannot fork from itself" + ); + let source_object = self + .objects + .get(source) + .context("theme fork lineage reference is missing")?; + ensure!( + matches!(source_object.object, BoardObject::Theme(_)), + "theme fork lineage reference is not a theme object" + ); + } + } BoardObject::Tombstone(value) => { let target = self.require_scoped(&value.target, &value.board, "")?; ensure!( @@ -694,6 +802,218 @@ impl BoardGraph { _ => false, }) } + + /// Compose the three-layer view of one board: the board layer + /// (signed moderation log + author tombstones + the board's theme), + /// the host layer (hashes the serving host will not serve), and the + /// personal layer (one viewer's own hides and muted authors). + /// + /// Transparency rule — hidden is not erased: a hidden object stays + /// in the graph, is omitted from [`BoardView::objects`], and is + /// reported in [`BoardView::hidden`] with the layer that hid it and, + /// for board-layer hides, the moderation action (or tombstone) that + /// did it. Every moderation action for the board is listed in + /// [`BoardView::moderation_log`] whether or not it currently hides + /// anything: the moderation log is public. + /// + /// Board-layer hide state uses the same order-independent existence + /// philosophy as role grants: a target is hidden while its signed + /// `Hide` records outnumber its signed `Restore` records, so every + /// peer holding the same log composes the same view in any arrival + /// order. When several layers hide one object, the reported layer + /// is the outermost applicable one: board, then host, then personal. + /// Superseded versions are not hidden and not shown — a projection + /// shows the replacement; the history stays in the graph. + pub fn compose_view(&self, board: &ArtifactHash, layers: &ViewLayers) -> Result { + let root = self.require_board(board)?; + let BoardObject::Board(root_body) = &root.object else { + unreachable!("require_board checked board kind") + }; + let theme = match &root_body.theme_ref { + Some(reference) => { + let theme_object = self + .objects + .get(reference) + .context("board theme reference is missing")?; + let BoardObject::Theme(theme) = &theme_object.object else { + bail!("board theme reference is not a theme object") + }; + Some(theme.clone()) + } + None => None, + }; + + // Board layer facts, gathered in deterministic (content-hash) + // order from the map. + let mut moderation_log = Vec::new(); + let mut hide_balance: BTreeMap = BTreeMap::new(); + let mut hide_source: BTreeMap)> = + BTreeMap::new(); + let mut tombstones: BTreeMap = BTreeMap::new(); + let mut superseded: BTreeSet = BTreeSet::new(); + for (hash, signed) in &self.objects { + match &signed.object { + BoardObject::ModerationAction(value) if &value.board == board => { + moderation_log.push(hash.clone()); + match value.action { + ModerationActionKind::Hide => { + *hide_balance.entry(value.target.clone()).or_default() += 1; + hide_source.insert( + value.target.clone(), + (hash.clone(), value.reason.clone()), + ); + } + ModerationActionKind::Restore => { + *hide_balance.entry(value.target.clone()).or_default() -= 1; + } + } + } + BoardObject::Tombstone(value) if &value.board == board => { + tombstones.entry(value.target.clone()).or_insert_with(|| hash.clone()); + } + BoardObject::Supersede(value) if &value.board == board => { + superseded.insert(value.previous.clone()); + } + _ => {} + } + } + + let mut objects = Vec::new(); + let mut hidden = Vec::new(); + for (hash, signed) in &self.objects { + if hash != board && signed.object.board_ref() != Some(board) { + continue; + } + // Log and relation records are not view content: the log is + // reported as `moderation_log`, and supersede/tombstone + // records act on their targets instead of being shown. + if matches!( + signed.object, + BoardObject::ModerationAction(_) + | BoardObject::Supersede(_) + | BoardObject::Tombstone(_) + ) { + continue; + } + if superseded.contains(hash) { + continue; + } + let board_hide = hide_balance.get(hash).copied().unwrap_or(0) > 0; + let tombstone = tombstones.get(hash); + if board_hide || tombstone.is_some() { + let (action, reason) = if board_hide { + let (action, reason) = hide_source + .get(hash) + .cloned() + .unwrap_or((hash.clone(), None)); + (Some(action), reason) + } else { + (tombstone.cloned(), Some("author tombstone".to_owned())) + }; + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Board, + action, + reason, + }); + } else if layers.host_hidden.contains(hash) { + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Host, + action: None, + reason: None, + }); + } else if layers.personal_hidden.contains(hash) + || layers.muted_authors.contains(&signed.artifact.author) + { + hidden.push(HiddenObject { + object: hash.clone(), + layer: ViewLayer::Personal, + action: None, + reason: None, + }); + } else { + objects.push(hash.clone()); + } + } + + Ok(BoardView { + board: board.clone(), + theme, + objects, + hidden, + moderation_log, + }) + } +} + +/// Which layer of a composed board view hid an object. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewLayer { + /// The viewer's own filter: personal hides and muted authors. + Personal, + /// The board's own signed record: moderation log and tombstones. + Board, + /// The serving host's policy set. + Host, +} + +/// One object a composed view hid, and the layer that hid it. +/// +/// The object itself stays in the graph — hidden is not erased — so +/// this entry (plus the public moderation log for board-layer hides) +/// is how a viewer, or an auditor, can tell that something is missing +/// from the projection and why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HiddenObject { + /// Hash of the hidden object. + pub object: ArtifactHash, + /// Layer that hid it (outermost applicable: board, host, personal). + pub layer: ViewLayer, + /// Moderation action or tombstone that hid it, for board-layer + /// hides; `None` for host and personal hides, which are not signed + /// graph records. + pub action: Option, + /// Recorded reason, when the hiding record carries one. + pub reason: Option, +} + +/// Caller-supplied outer layers for [`BoardGraph::compose_view`]. +/// +/// Host and personal filters are serving/viewing state, not signed +/// graph objects — a host's policy and a viewer's mutes are theirs to +/// keep — so they are inputs to the projection, never inserted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ViewLayers { + /// Objects this viewer has personally hidden. + pub personal_hidden: BTreeSet, + /// Authors (canonical Ed25519 public keys) this viewer has muted. + pub muted_authors: BTreeSet, + /// Objects the serving host will not serve. + pub host_hidden: BTreeSet, +} + +/// A composed three-layer view of one board. This is a projection of +/// the graph, not a copy of it: every hash names an object that stays +/// in the graph, hidden ones included. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoardView { + /// Board root hash this view was composed for. + pub board: ArtifactHash, + /// Theme the board applies by reference, resolved from the graph. + pub theme: Option, + /// Visible current objects: the board root and its content objects, + /// in deterministic content-hash order. + pub objects: Vec, + /// Objects hidden by one of the three layers, with the layer and + /// (for board-layer hides) the record that hid them. + pub hidden: Vec, + /// Every moderation action recorded for this board, in + /// deterministic content-hash order. The moderation log is public: + /// it is listed whether or not each entry currently hides anything. + pub moderation_log: Vec, } fn validate_board_id(value: &str) -> Result<()> { @@ -717,6 +1037,17 @@ fn validate_text(name: &str, value: &str, allow_empty: bool) -> Result<()> { Ok(()) } +fn validate_token_name(value: &str) -> Result<()> { + ensure!( + (1..=64).contains(&value.len()) + && value.bytes().all(|byte| byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || b"-_.".contains(&byte)), + "theme token name must be 1-64 lower-case ASCII letters, digits, '-', '_' or '.'" + ); + Ok(()) +} + fn is_public_key(value: &str) -> bool { if value.len() != 64 || !value @@ -756,6 +1087,7 @@ mod tests { BoardObject::Board(Board { board_id: "retro-games".to_owned(), forked_from: None, + theme_ref: None, }), ) .unwrap() @@ -783,6 +1115,76 @@ mod tests { .unwrap() } + fn theme(key: &SigningKey, seq: u64, forked_from: Option) -> SignedBoardObject { + let mut tokens = BTreeMap::new(); + tokens.insert("accent".to_owned(), "#cc5500".to_owned()); + tokens.insert("surface".to_owned(), "#101418".to_owned()); + SignedBoardObject::sign( + key, + seq, + "", + BoardObject::Theme(Theme { + name: "Ember".to_owned(), + tokens, + layout: ThemeLayout::Grid, + assets: Vec::new(), + forked_from, + }), + ) + .unwrap() + } + + fn themed_board(key: &SigningKey, theme_ref: ArtifactHash) -> SignedBoardObject { + SignedBoardObject::sign( + key, + 1, + "", + BoardObject::Board(Board { + board_id: "themed-board".to_owned(), + forked_from: None, + theme_ref: Some(theme_ref), + }), + ) + .unwrap() + } + + fn thread(key: &SigningKey, board: ArtifactHash, title: &str) -> SignedBoardObject { + SignedBoardObject::sign( + key, + 1, + "", + BoardObject::Thread(Thread { + board, + forum_ref: None, + rules_ref: None, + title: title.to_owned(), + }), + ) + .unwrap() + } + + fn moderation( + key: &SigningKey, + seq: u64, + board: ArtifactHash, + target: ArtifactHash, + action: ModerationActionKind, + reason: Option<&str>, + ) -> SignedBoardObject { + SignedBoardObject::sign( + key, + seq, + "", + BoardObject::ModerationAction(ModerationAction { + board, + target, + action, + reason: reason.map(str::to_owned), + }), + ) + .unwrap() + } + #[test] fn signed_typed_objects_round_trip_and_reject_forgery() { let signer = key(1); @@ -1136,4 +1538,198 @@ mod tests { .unwrap(); assert!(graph.insert(ungranted_forum).is_err()); } -} + #[test] + fn theme_fork_lineage_must_name_an_existing_theme() { + let author = key(20); + let mut graph = BoardGraph::default(); + let base = graph.insert(theme(&author, 1, None)).unwrap(); + graph.insert(theme(&author, 2, Some(base))).unwrap(); + + let missing = ArtifactHash::new("b".repeat(64)).unwrap(); + assert!(graph.insert(theme(&author, 3, Some(missing))).is_err()); + + let root_hash = graph.insert(board(&author)).unwrap(); + assert!(graph.insert(theme(&author, 4, Some(root_hash))).is_err()); + } + + #[test] + fn theme_shape_rejects_bad_tokens_and_duplicate_assets() { + let author = key(21); + let mut bad_name = BTreeMap::new(); + bad_name.insert("Accent".to_owned(), "#ffffff".to_owned()); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: bad_name, + layout: ThemeLayout::List, + assets: Vec::new(), + forked_from: None, + }), + ) + .is_err()); + + let mut empty_value = BTreeMap::new(); + empty_value.insert("accent".to_owned(), String::new()); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: empty_value, + layout: ThemeLayout::List, + assets: Vec::new(), + forked_from: None, + }), + ) + .is_err()); + + let asset = ArtifactHash::new("c".repeat(64)).unwrap(); + assert!(SignedBoardObject::sign( + &author, + 1, + "", + BoardObject::Theme(Theme { + name: "Bad".to_owned(), + tokens: BTreeMap::new(), + layout: ThemeLayout::Compact, + assets: vec![asset.clone(), asset], + forked_from: None, + }), + ) + .is_err()); + } + + #[test] + fn board_theme_ref_applies_a_theme_by_reference() { + let author = key(22); + let mut graph = BoardGraph::default(); + let theme_signed = theme(&author, 1, None); + let theme_hash = graph.insert(theme_signed.clone()).unwrap(); + + // A board naming a theme that is not in the graph is rejected... + let missing = ArtifactHash::new("d".repeat(64)).unwrap(); + assert!(graph.insert(themed_board(&author, missing)).is_err()); + // ...as is a board whose theme_ref names a non-theme object. + let other_board = graph.insert(board(&author)).unwrap(); + assert!(graph.insert(themed_board(&author, other_board)).is_err()); + + let root_hash = graph.insert(themed_board(&author, theme_hash)).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + let BoardObject::Theme(expected) = theme_signed.object else { + unreachable!("theme helper builds a theme") + }; + assert_eq!(view.theme, Some(expected)); + assert!(view.objects.contains(&root_hash)); + assert!(view.hidden.is_empty()); + + // Board bodies written before theme_ref existed still decode. + let legacy: Board = + serde_json::from_str(r#"{"board_id":"legacy","forked_from":null}"#).unwrap(); + assert_eq!(legacy.theme_ref, None); + } + + #[test] + fn layered_view_hides_transparently_and_restore_unhides() { + let owner = key(23); + let author = key(24); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "buy my stuff")) + .unwrap(); + + let hide = moderation( + &owner, + 2, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + ); + let hide_hash = graph.insert(hide).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(!view.objects.contains(&post_hash)); + assert_eq!(view.moderation_log, vec![hide_hash.clone()]); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, post_hash); + assert_eq!(view.hidden[0].layer, ViewLayer::Board); + assert_eq!(view.hidden[0].action, Some(hide_hash)); + assert_eq!(view.hidden[0].reason.as_deref(), Some("spam")); + // Hidden is not erased: the object is still in the graph. + assert!(graph.get(&post_hash).is_some()); + + let restore = moderation( + &owner, + 3, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Restore, + None, + ); + graph.insert(restore).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + assert_eq!(view.moderation_log.len(), 2); + + // A second hide outnumbers the restore and hides it again. + let rehide = moderation( + &owner, + 4, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam again"), + ); + graph.insert(rehide).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].reason.as_deref(), Some("spam again")); + } + + #[test] + fn personal_and_host_layers_hide_without_a_board_record() { + let owner = key(25); + let alice = key(26); + let bob = key(27); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let alice_post = graph + .insert(post(&alice, root_hash.clone(), thread_hash.clone(), "alice")) + .unwrap(); + let bob_post = graph + .insert(post(&bob, root_hash.clone(), thread_hash, "bob")) + .unwrap(); + + let mut layers = ViewLayers::default(); + layers + .muted_authors + .insert(hex_encode(&alice.verifying_key().to_bytes())); + layers.host_hidden.insert(bob_post.clone()); + let view = graph.compose_view(&root_hash, &layers).unwrap(); + assert!(view.moderation_log.is_empty()); + assert_eq!(view.hidden.len(), 2); + let by_object: BTreeMap<_, _> = view + .hidden + .iter() + .map(|entry| (entry.object.clone(), entry.layer)) + .collect(); + assert_eq!(by_object[&alice_post], ViewLayer::Personal); + assert_eq!(by_object[&bob_post], ViewLayer::Host); + assert!(!view.objects.contains(&alice_post)); + assert!(!view.objects.contains(&bob_post)); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&alice_post)); + assert!(view.objects.contains(&bob_post)); + assert!(view.hidden.is_empty()); + } + +} \ No newline at end of file From 2333c6d4296f5ed9273448e7375277b9903673cb Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 1 Nov 2026 15:27:12 -0600 Subject: [PATCH 2/3] fix(id): enforce view-layer authority and record semantics for #2813 (Codex review) Dispositions all four Codex findings on PR #3971: (P1) compose_view resolves the current moderator frontier via signed supersedes links - a revoked moderator's actions stay in the public log but lose effect; (P2) superseded moderation/tombstone records have no projection effects; (P2) hidden entries preserve the signed tombstone reason; (P2) host/personal layers can suppress the board theme, reported in hidden. Three new tests cover revocation, superseded tombstones with reasons, and theme suppression. --- pkgs/id/src/boards.rs | 243 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 8 deletions(-) diff --git a/pkgs/id/src/boards.rs b/pkgs/id/src/boards.rs index 4817a9bd6..8c26a1f6e 100644 --- a/pkgs/id/src/boards.rs +++ b/pkgs/id/src/boards.rs @@ -824,11 +824,25 @@ impl BoardGraph { /// is the outermost applicable one: board, then host, then personal. /// Superseded versions are not hidden and not shown — a projection /// shows the replacement; the history stays in the graph. + /// + /// Two enforcement duties the substrate deliberately delegates to + /// this layer (see [`BoardGraph::has_role`]) are discharged here: + /// a moderation record has an effect only while its author holds + /// authority — the board owner, or a moderator whose grant is on + /// the current attestation frontier (a grant superseded by a + /// revocation no longer empowers; the frontier follows the signed + /// `supersedes` links, not arrival order) — and a superseded + /// moderation or tombstone record has no effect. Revoked or + /// superseded records stay in the public moderation log either + /// way. The board's theme is itself subject to the host and + /// personal layers: a suppressed theme is not returned, and the + /// suppression is reported in `hidden` like any other. pub fn compose_view(&self, board: &ArtifactHash, layers: &ViewLayers) -> Result { let root = self.require_board(board)?; let BoardObject::Board(root_body) = &root.object else { unreachable!("require_board checked board kind") }; + let mut theme_hidden = None; let theme = match &root_body.theme_ref { Some(reference) => { let theme_object = self @@ -838,23 +852,98 @@ impl BoardGraph { let BoardObject::Theme(theme) = &theme_object.object else { bail!("board theme reference is not a theme object") }; - Some(theme.clone()) + if layers.host_hidden.contains(reference) { + theme_hidden = Some(HiddenObject { + object: reference.clone(), + layer: ViewLayer::Host, + action: None, + reason: None, + }); + None + } else if layers.personal_hidden.contains(reference) + || layers.muted_authors.contains(&theme_object.artifact.author) + { + theme_hidden = Some(HiddenObject { + object: reference.clone(), + layer: ViewLayer::Personal, + action: None, + reason: None, + }); + None + } else { + Some(theme.clone()) + } } None => None, }; + // Supersede relations first: a superseded record (a moderation + // action, a tombstone, any content object) has no effect in the + // projection, whatever it once said. + let mut superseded: BTreeSet = BTreeSet::new(); + for signed in self.objects.values() { + if let BoardObject::Supersede(value) = &signed.object { + if &value.board == board { + superseded.insert(value.previous.clone()); + } + } + } + + // Current moderator frontier: a role attestation that another + // attestation supersedes is history, not authority. Following + // the signed supersedes links (never arrival order) keeps this + // identical on every peer holding the same set. + let mut attestation_superseded: BTreeSet = BTreeSet::new(); + for signed in self.objects.values() { + if let BoardObject::RoleAttestation(value) = &signed.object { + if &value.board == board { + if let Some(previous) = &value.supersedes { + attestation_superseded.insert(previous.clone()); + } + } + } + } + let current_moderators: BTreeSet = self + .objects + .iter() + .filter(|(hash, _)| !attestation_superseded.contains(hash)) + .filter_map(|(_, signed)| match &signed.object { + BoardObject::RoleAttestation(value) + if &value.board == board + && value.role == BoardRole::Moderator + && value.granted => + { + Some(value.member.clone()) + } + _ => None, + }) + .collect(); + let owner = root.artifact.author.as_str(); + let has_authority = |author: &str| { + author == owner || current_moderators.contains(author) + }; + // Board layer facts, gathered in deterministic (content-hash) // order from the map. let mut moderation_log = Vec::new(); let mut hide_balance: BTreeMap = BTreeMap::new(); let mut hide_source: BTreeMap)> = BTreeMap::new(); - let mut tombstones: BTreeMap = BTreeMap::new(); - let mut superseded: BTreeSet = BTreeSet::new(); + let mut tombstones: BTreeMap)> = + BTreeMap::new(); for (hash, signed) in &self.objects { match &signed.object { BoardObject::ModerationAction(value) if &value.board == board => { + // The log is public and complete: every action is + // listed, including ones that no longer (or never + // did) carry authority. Only empowered, current + // records change the projection. moderation_log.push(hash.clone()); + if superseded.contains(hash) + || !has_authority(signed.artifact.author.as_str()) + { + continue; + } match value.action { ModerationActionKind::Hide => { *hide_balance.entry(value.target.clone()).or_default() += 1; @@ -869,10 +958,11 @@ impl BoardGraph { } } BoardObject::Tombstone(value) if &value.board == board => { - tombstones.entry(value.target.clone()).or_insert_with(|| hash.clone()); - } - BoardObject::Supersede(value) if &value.board == board => { - superseded.insert(value.previous.clone()); + if !superseded.contains(hash) { + tombstones + .entry(value.target.clone()) + .or_insert_with(|| (hash.clone(), value.reason.clone())); + } } _ => {} } @@ -880,6 +970,9 @@ impl BoardGraph { let mut objects = Vec::new(); let mut hidden = Vec::new(); + if let Some(entry) = theme_hidden { + hidden.push(entry); + } for (hash, signed) in &self.objects { if hash != board && signed.object.board_ref() != Some(board) { continue; @@ -908,7 +1001,9 @@ impl BoardGraph { .unwrap_or((hash.clone(), None)); (Some(action), reason) } else { - (tombstone.cloned(), Some("author tombstone".to_owned())) + let (action, reason) = + tombstone.cloned().unwrap_or((hash.clone(), None)); + (Some(action), reason) }; hidden.push(HiddenObject { object: hash.clone(), @@ -1732,4 +1827,136 @@ mod tests { assert!(view.hidden.is_empty()); } + #[test] + fn view_enforces_moderator_revocation_but_keeps_the_public_log() { + let owner = key(30); + let moderator = key(31); + let author = key(32); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "hello")) + .unwrap(); + let grant = SignedBoardObject::sign( + &owner, + 2, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted: true, + supersedes: None, + }), + ) + .unwrap(); + let grant_hash = graph.insert(grant).unwrap(); + let hide_hash = graph + .insert(moderation( + &moderator, + 1, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + )) + .unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, post_hash); + + let revoke = SignedBoardObject::sign( + &owner, + 3, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted: false, + supersedes: Some(grant_hash), + }), + ) + .unwrap(); + graph.insert(revoke).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + // The revoked moderator's hide no longer has an effect... + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + // ...but it stays in the public moderation log. + assert_eq!(view.moderation_log, vec![hide_hash]); + } + + #[test] + fn superseded_tombstone_loses_effect_and_keeps_its_signed_reason() { + let owner = key(33); + let author = key(34); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let first = graph + .insert(post(&author, root_hash.clone(), thread_hash.clone(), "first")) + .unwrap(); + let second = graph + .insert(post(&author, root_hash.clone(), thread_hash, "second")) + .unwrap(); + let tombstone = |target: ArtifactHash, reason: &str| { + SignedBoardObject::sign( + &author, + 2, + "", + BoardObject::Tombstone(Tombstone { + board: root_hash.clone(), + target, + reason: Some(reason.to_owned()), + }), + ) + .unwrap() + }; + let first_tombstone = graph.insert(tombstone(first.clone(), "wrong thread")).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, first); + assert_eq!(view.hidden[0].reason.as_deref(), Some("wrong thread")); + + let second_tombstone = + graph.insert(tombstone(second.clone(), "duplicate")).unwrap(); + let supersede = SignedBoardObject::sign( + &author, + 3, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous: first_tombstone, + replacement: second_tombstone, + }), + ) + .unwrap(); + graph.insert(supersede).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&first)); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].object, second); + assert_eq!(view.hidden[0].reason.as_deref(), Some("duplicate")); + } + + #[test] + fn host_layer_can_suppress_the_board_theme() { + let author = key(35); + let mut graph = BoardGraph::default(); + let theme_hash = graph.insert(theme(&author, 1, None)).unwrap(); + let root_hash = graph.insert(themed_board(&author, theme_hash.clone())).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.theme.is_some()); + + let mut layers = ViewLayers::default(); + layers.host_hidden.insert(theme_hash); + let view = graph.compose_view(&root_hash, &layers).unwrap(); + assert!(view.theme.is_none()); + assert_eq!(view.hidden.len(), 1); + assert_eq!(view.hidden[0].layer, ViewLayer::Host); + } + } \ No newline at end of file From 3f6d45fe573c65a6458eadeab751e0f384bee931 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 1 Nov 2026 15:44:04 -0600 Subject: [PATCH 3/3] fix(id): active-supersede frontier for view authority and relations (#2813, Codex round 2) Two round-2 Codex findings on PR #3971: a grant replaced through a generic Supersede record (not the attestation supersedes field) now also leaves the authority frontier, and supersede relations are resolved to an active frontier by fixed point - a superseded supersede record contributes no edge. Two tests cover both mechanisms. --- pkgs/id/src/boards.rs | 150 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/pkgs/id/src/boards.rs b/pkgs/id/src/boards.rs index 8c26a1f6e..981fa4193 100644 --- a/pkgs/id/src/boards.rs +++ b/pkgs/id/src/boards.rs @@ -879,15 +879,47 @@ impl BoardGraph { // Supersede relations first: a superseded record (a moderation // action, a tombstone, any content object) has no effect in the - // projection, whatever it once said. - let mut superseded: BTreeSet = BTreeSet::new(); - for signed in self.objects.values() { - if let BoardObject::Supersede(value) = &signed.object { - if &value.board == board { - superseded.insert(value.previous.clone()); + // projection, whatever it once said. A supersede record can + // itself be superseded, so the active records are a frontier, + // resolved to a fixed point: a record stays active while no + // active record names it as its previous version. (Supersede + // cycles cannot exist — insertion rejects an edge that would + // close one — so this terminates with the true frontier.) Only + // active records contribute their edges to the superseded set. + let supersede_records: Vec<(ArtifactHash, ArtifactHash)> = self + .objects + .iter() + .filter_map(|(hash, signed)| match &signed.object { + BoardObject::Supersede(value) if &value.board == board => { + Some((hash.clone(), value.previous.clone())) } + _ => None, + }) + .collect(); + let mut active_records: BTreeSet = supersede_records + .iter() + .map(|(hash, _)| hash.clone()) + .collect(); + loop { + let named: BTreeSet = supersede_records + .iter() + .filter(|(hash, _)| active_records.contains(hash)) + .map(|(_, previous)| previous.clone()) + .collect(); + let next: BTreeSet = active_records + .difference(&named) + .cloned() + .collect(); + if next == active_records { + break; } + active_records = next; } + let superseded: BTreeSet = supersede_records + .iter() + .filter(|(hash, _)| active_records.contains(hash)) + .map(|(_, previous)| previous.clone()) + .collect(); // Current moderator frontier: a role attestation that another // attestation supersedes is history, not authority. Following @@ -906,7 +938,9 @@ impl BoardGraph { let current_moderators: BTreeSet = self .objects .iter() - .filter(|(hash, _)| !attestation_superseded.contains(hash)) + .filter(|(hash, _)| { + !attestation_superseded.contains(hash) && !superseded.contains(hash) + }) .filter_map(|(_, signed)| match &signed.object { BoardObject::RoleAttestation(value) if &value.board == board @@ -1959,4 +1993,106 @@ mod tests { assert_eq!(view.hidden[0].layer, ViewLayer::Host); } + #[test] + fn generic_supersede_of_a_grant_also_revokes_authority() { + let owner = key(36); + let moderator = key(37); + let author = key(38); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let post_hash = graph + .insert(post(&author, root_hash.clone(), thread_hash, "hello")) + .unwrap(); + let attestation = |granted: bool| { + SignedBoardObject::sign( + &owner, + 2, + "", + BoardObject::RoleAttestation(RoleAttestation { + board: root_hash.clone(), + member: hex_encode(&moderator.verifying_key().to_bytes()), + role: BoardRole::Moderator, + granted, + supersedes: None, + }), + ) + .unwrap() + }; + let grant_hash = graph.insert(attestation(true)).unwrap(); + graph + .insert(moderation( + &moderator, + 1, + root_hash.clone(), + post_hash.clone(), + ModerationActionKind::Hide, + Some("spam"), + )) + .unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert_eq!(view.hidden.len(), 1); + + // Revocation by generic Supersede record (not the attestation's + // own supersedes field) must end the authority just the same. + let revoke_hash = graph.insert(attestation(false)).unwrap(); + let supersede = SignedBoardObject::sign( + &owner, + 3, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous: grant_hash, + replacement: revoke_hash, + }), + ) + .unwrap(); + graph.insert(supersede).unwrap(); + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + assert!(view.objects.contains(&post_hash)); + assert!(view.hidden.is_empty()); + assert_eq!(view.moderation_log.len(), 1); + } + + #[test] + fn superseded_supersede_record_has_no_effect() { + let owner = key(39); + let author = key(40); + let mut graph = BoardGraph::default(); + let root_hash = graph.insert(board(&owner)).unwrap(); + let thread_hash = graph.insert(thread(&owner, root_hash.clone(), "General")).unwrap(); + let mut posts = Vec::new(); + for body in ["a", "b", "c", "d"] { + posts.push( + graph + .insert(post(&author, root_hash.clone(), thread_hash.clone(), body)) + .unwrap(), + ); + } + let supersede = |seq: u64, previous: ArtifactHash, replacement: ArtifactHash| { + SignedBoardObject::sign( + &author, + seq, + "", + BoardObject::Supersede(Supersede { + board: root_hash.clone(), + previous, + replacement, + }), + ) + .unwrap() + }; + let first = graph.insert(supersede(2, posts[0].clone(), posts[1].clone())).unwrap(); + let second = graph.insert(supersede(3, posts[2].clone(), posts[3].clone())).unwrap(); + graph.insert(supersede(4, first, second)).unwrap(); + + let view = graph.compose_view(&root_hash, &ViewLayers::default()).unwrap(); + // The first supersede was itself superseded: post A is current + // again. The second supersede is active: post C is replaced. + assert!(view.objects.contains(&posts[0])); + assert!(view.objects.contains(&posts[1])); + assert!(!view.objects.contains(&posts[2])); + assert!(view.objects.contains(&posts[3])); + } + } \ No newline at end of file