fix: comment retention taxonomy — TTL by (post policy x signing class)

Retention was keyed on comment type, so public and post-key-signed comments
carried a randomized 30-365d expiry inside their signed digest. Under the
ruling neither should ever expire; only the private, non-post-key-signed
(open-slot/greeting) channel gets an automatic TTL, since that is the
throwaway-identity retirement mechanism.

New crates/core/src/comment_ttl.rs is the single authority: CommentClass
(Public / PostKeySigned / OpenSlot / Unverifiable) x CommentTtlRule
(Never | Window | UnknownParent), with draw_expiry (writer) and ttl_ok
(holder). expires_at_ms == 0 is the never-expires sentinel, honored by the
sweep, the ingest gate, and the store_comment upsert.

- Post.comment_ttl: Option<CommentTtlPolicy> — a GENERIC per-post policy, so
  an author-set TTL is a future config surface, not a redesign. Registry posts
  declare a flat 30d policy that binds EVERY comment on them (registrations,
  duplicate reports, anything else), replacing the registration-only rule.
- OpenSlotDecl.max_comments: author-declarable cap on private PK-unsigned
  comments, enforced holder-side (clamped to the holder default), replacing
  the hardcoded per-bio greeting cap. Refusal remains "declare no slot".
  Node::set_greetings_max + `greetings-max` CLI command to write it.
- Holder enforcement rejects TTLs contradicting the parent's policy in both
  directions; a comment naming a different post than its envelope is rejected.
- UnknownParent rule: bounded TTLs accepted from unheld parents (self-heal),
  never-expires refused — permanence is not granted on unseen evidence.

Also fixed while here: five Post-reconstructing queries silently dropped
comment_ttl AND the pre-existing fof_gating (shipped in v0.8.0-alpha), so any
gated or policy-carrying post failed BLAKE3 verification on sync/export and
was discarded with no diagnostic. All hydration now goes through one
POST_COLUMNS/post_from_row path; export/import round-trips the policy.

Registry frozen bytes regenerated for the policy field; REGISTRY_POST_ID is
now 10a1be3383efb2977607fe45c4a7b3f1b5e626e81d0ac1af9c0f3d7eb9864d32.
design.html section 21 rewritten to the corrected taxonomy.

250 core tests (was 228); a3 integration 12/12 (new step 6 asserts registry
comments hold exactly 30d while greetings randomize); c_topology 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-08-03 15:33:44 -04:00
parent fa02ace4cc
commit 8b042f6598
20 changed files with 1676 additions and 255 deletions

View file

@ -145,6 +145,7 @@ pub fn build_announcement_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}

View file

@ -0,0 +1,480 @@
//! v0.8: comment retention — the single place that answers "how long may
//! THIS comment live on THAT post?".
//!
//! Ruling (2026-08-03): retention is decided by
//! **(parent post's comment-TTL policy) × (comment's signing class)**:
//!
//! | parent policy | signing class | rule |
//! |---|---|---|
//! | `Some(p)` | any | window `p` (registry post: flat 30d) |
//! | `None` | public (no slot) | **never expires** |
//! | `None` | post-key-signed (FoF/member) | **never expires** |
//! | `None` | open slot (stranger/greeting) | randomized 30365d |
//! | parent NOT HELD | any | [`CommentTtlRule::UnknownParent`] |
//!
//! The author policy is a plain field on the `Post`
//! ([`crate::types::CommentTtlPolicy`]) — the registry post gets its flat
//! 30 days through exactly the mechanism a future author-set TTL would
//! use, NOT through an `if is_registry_post` branch. To ship author-set
//! TTLs later: populate `Post.comment_ttl` in the composer path; every
//! writer and every holder-side check below already honours it.
//!
//! ## The `0` sentinel
//! `InlineComment.expires_at_ms == 0` means **never expires**. It is
//! inside the signed comment digest (crypto.rs digest v2), so the author
//! commits to it and holders can enforce it. Storage already maps `0` to
//! `NULL` and every read filter treats `NULL`/`0` as "not expired", so
//! the sweep ([`crate::storage::Storage::expire_comments`]) leaves them
//! alone.
//!
//! ## Why holders enforce, not just writers
//! A comment's TTL is self-asserted. Without holder-side validation a
//! spammer submits a no-expiry registration (permanent registry slot) or
//! a 10-year greeting (permanent squat on someone's 64-greeting budget).
//! [`ttl_ok`] is therefore run on EVERY ingest path, alongside the
//! signature check.
//!
//! ## Unheld parents: permanence is never granted on unseen evidence
//! A holder that does NOT hold the parent post has never seen its
//! retention policy. Collapsing that case to the class default would let
//! it assert "may never expire" about a post whose policy it cannot
//! read — and a `0`-expiry comment is permanent storage, the one
//! property that must never be granted on unverifiable evidence (a
//! second registry shard, or any future author-set TTL, would otherwise
//! be bypassable by offering the comment to non-holders).
//! [`CommentTtlRule::UnknownParent`] therefore accepts only a BOUNDED
//! TTL (≤ [`MAX_COMMENT_TTL_MS`]) and refuses the `0` sentinel: such a
//! comment self-heals — it is re-offered once the post is held (the
//! header-pull path re-offers, and holders re-derive their own headers).
//! Writer-side the same rule draws `0` ([`draw_expiry`]): the author of
//! an ordinary comment on a post they do not hold assumes "no policy",
//! which is the class default their own holders will confirm.
use crate::types::{CommentTtlPolicy, InlineComment, OpenSlotKind, Post};
/// Default randomized window for the open-slot stranger channel:
/// `rand(30..=365 days)`. The randomness is identity hygiene — throwaway
/// commenter IDs must not all retire on a predictable schedule.
pub const OPEN_SLOT_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const OPEN_SLOT_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// Absolute holder-side ceiling on any accepted TTL. A post policy that
/// asks for more is clamped to this; a comment claiming more is dropped.
pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000;
/// Clock-skew slack when checking a claimed TTL against a policy window.
pub const TTL_SLACK_MS: u64 = 5 * 60 * 1000;
/// What a comment's signature evidences about its author's standing on
/// the parent post. Computable by any holder from the parent post alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentClass {
/// No `pub_x_index`: an ordinary public comment.
Public,
/// Signed under a post-key slot that is NOT the open slot — evidence
/// of authorized/FoF membership.
PostKeySigned,
/// Signed under the post's OPEN slot, whose key anyone can derive.
/// Carries no authorization evidence: the greeting/registry stranger
/// channel. This is the only class with an automatic TTL.
OpenSlot,
/// Claims a slot (`pub_x_index`) on a post we do NOT hold, so the
/// claim cannot be checked against any `pub_post_set` — we cannot
/// tell an open-slot greeting from a member comment. Retention-wise
/// it resolves to [`CommentTtlRule::UnknownParent`] like anything
/// else on an unheld parent; the actual rejection belongs to the
/// ingest gate's slot-verification step, which attributes the drop
/// to the unverifiable slot claim rather than to a TTL mismatch.
Unverifiable,
}
/// The retention rule that applies to one comment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentTtlRule {
/// `expires_at_ms` MUST be the `0` sentinel.
Never,
/// `expires_at_ms - timestamp_ms` must land in the inclusive window
/// (±[`TTL_SLACK_MS`]). `min == max` is a flat TTL.
Window { min_ttl_ms: u64, max_ttl_ms: u64 },
/// We do not hold the parent post, so its retention policy is
/// unseen. Bounded TTLs (≤ [`MAX_COMMENT_TTL_MS`]) are accepted —
/// they self-heal — but the `0` never-expires sentinel is REFUSED:
/// permanence is never granted on evidence we cannot read. See the
/// module header.
UnknownParent,
}
/// Classify a comment against its parent post. With no `pub_x_index` the
/// comment is [`CommentClass::Public`] whether or not we hold the parent.
/// A slot claim on an UNHELD parent is [`CommentClass::Unverifiable`] —
/// not `PostKeySigned` — because nothing was verified: the ingest gate
/// drops it at slot verification, and attributing it correctly here
/// keeps that drop legible.
pub fn classify(parent: Option<&Post>, comment: &InlineComment) -> CommentClass {
let Some(idx) = comment.pub_x_index else {
return CommentClass::Public;
};
let Some(parent) = parent else {
return CommentClass::Unverifiable;
};
let open_idx = parent
.fof_gating
.as_ref()
.and_then(|g| g.open_slot.as_ref())
.map(|d| d.slot_index);
if open_idx == Some(idx) {
CommentClass::OpenSlot
} else {
CommentClass::PostKeySigned
}
}
/// Resolve the retention rule for a (parent post, signing class) pair.
/// An author policy wins over the class default for EVERY class — that
/// is what makes "all comments on a registry post expire at 30d" true of
/// duplicate-reports and any other comment, not just registrations.
pub fn rule_for(parent: Option<&Post>, class: CommentClass) -> CommentTtlRule {
let Some(parent) = parent else {
// We have never seen this post's policy — see the module header.
return CommentTtlRule::UnknownParent;
};
if let Some(policy) = parent.comment_ttl.as_ref() {
return clamp_policy(policy);
}
match class {
// Ruling: public and post-key-signed comments never auto-expire.
CommentClass::Public | CommentClass::PostKeySigned => CommentTtlRule::Never,
// Ruling: the open-slot stranger channel is the throwaway-ID
// retirement mechanism and the ONLY automatic TTL.
CommentClass::OpenSlot => CommentTtlRule::Window {
min_ttl_ms: OPEN_SLOT_TTL_MIN_MS,
max_ttl_ms: OPEN_SLOT_TTL_MAX_MS,
},
// Only reachable with `parent = None`, handled above.
CommentClass::Unverifiable => CommentTtlRule::UnknownParent,
}
}
/// Convenience: [`classify`] + [`rule_for`].
pub fn rule_for_comment(parent: Option<&Post>, comment: &InlineComment) -> CommentTtlRule {
rule_for(parent, classify(parent, comment))
}
/// Clamp an author-declared policy into what holders will store. Authors
/// may shorten retention freely; they may not buy more of a stranger's
/// disk than [`MAX_COMMENT_TTL_MS`].
fn clamp_policy(policy: &CommentTtlPolicy) -> CommentTtlRule {
let max = policy.max_ttl_ms.min(MAX_COMMENT_TTL_MS);
let min = policy.min_ttl_ms.min(max);
CommentTtlRule::Window { min_ttl_ms: min, max_ttl_ms: max }
}
/// Pick the `expires_at_ms` a NEW comment must carry under `rule`.
/// Returns the `0` sentinel for [`CommentTtlRule::Never`] and for
/// [`CommentTtlRule::UnknownParent`] (writer-side "assume no policy" —
/// see the module header; holders that hold the post confirm it).
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` shortens a
/// *randomized* window (`min < max` — i.e. the open-slot greeting
/// channel it was written for) to n seconds, in debug builds only. It
/// deliberately does NOT touch:
/// * [`CommentTtlRule::Never`] — "no expiry" is a correctness
/// property, not a duration knob; and
/// * FLAT windows (`min == max`), i.e. an author-declared policy such
/// as the registry post's 30 days. Shortening those would make every
/// registration expire in n seconds and pin the auto-renew loop
/// permanently "due" (`renew_registry_entries_if_due`).
pub fn draw_expiry(rule: CommentTtlRule, now_ms: u64) -> u64 {
match rule {
CommentTtlRule::Never | CommentTtlRule::UnknownParent => 0,
CommentTtlRule::Window { min_ttl_ms, max_ttl_ms } => {
if let Some(secs) = override_secs_for(min_ttl_ms, max_ttl_ms) {
return now_ms + secs * 1000;
}
if min_ttl_ms >= max_ttl_ms {
// Flat policy: a correctness constraint holders enforce
// exactly. No test override here — see the doc above.
return now_ms + min_ttl_ms;
}
use rand::Rng;
now_ms + rand::rng().random_range(min_ttl_ms..=max_ttl_ms)
}
}
}
/// Scope of the `ITSGOIN_TEST_TTL_SECS` debug override, as a pure
/// function of the window and the env value — the env read itself lives
/// in [`override_secs_for`]. A FLAT window (`min == max`) is an author
/// policy such as the registry's 30 days: a correctness constraint, not
/// a duration knob, so the override never touches it.
fn override_scope(min_ttl_ms: u64, max_ttl_ms: u64, secs: Option<u64>) -> Option<u64> {
match secs {
Some(s) if min_ttl_ms < max_ttl_ms => Some(s),
_ => None,
}
}
/// [`override_scope`] against the live environment (debug builds only).
fn override_secs_for(min_ttl_ms: u64, max_ttl_ms: u64) -> Option<u64> {
#[cfg(debug_assertions)]
{
let secs = std::env::var("ITSGOIN_TEST_TTL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok());
override_scope(min_ttl_ms, max_ttl_ms, secs)
}
#[cfg(not(debug_assertions))]
{
override_scope(min_ttl_ms, max_ttl_ms, None)
}
}
/// Holder-side check: does this comment's self-asserted expiry match the
/// rule its parent post imposes? Rejects in BOTH directions — a
/// no-expiry comment on a TTL'd post, and an over-long TTL on the
/// stranger channel.
pub fn ttl_ok(rule: CommentTtlRule, timestamp_ms: u64, expires_at_ms: u64) -> bool {
match rule {
CommentTtlRule::Never => expires_at_ms == 0,
// Parent unheld: bounded TTLs only — never the `0` sentinel.
CommentTtlRule::UnknownParent => ttl_ok(
CommentTtlRule::Window { min_ttl_ms: 0, max_ttl_ms: MAX_COMMENT_TTL_MS },
timestamp_ms,
expires_at_ms,
),
CommentTtlRule::Window { min_ttl_ms, max_ttl_ms } => {
// The test override shortens RANDOMIZED windows below their
// real floor; accept anything inside the ceiling then. Flat
// policies (min == max) keep their floor — they are a
// correctness constraint, not a duration knob.
let min_ttl_ms = if override_secs_for(min_ttl_ms, max_ttl_ms).is_some() {
0
} else {
min_ttl_ms
};
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else {
return false; // 0 sentinel or backdated expiry
};
if expires_at_ms == 0 {
return false;
}
ttl + TTL_SLACK_MS >= min_ttl_ms && ttl <= max_ttl_ms.saturating_add(TTL_SLACK_MS)
}
}
}
/// Holder-side ceiling on live comments in a post's open slot: the
/// author's declared limit if any, clamped to the holder's own default
/// for that slot kind. `None` slot ⇒ caller doesn't apply a cap.
pub fn open_slot_limit(decl: &crate::types::OpenSlotDecl, holder_default: u64) -> u64 {
match decl.max_comments {
Some(n) => u64::from(n).min(holder_default),
None => holder_default,
}
}
/// Holder default ceiling per open-slot kind. Greeting slots are the
/// flood-exposed surface (round 8: rate caps + size buckets ARE the
/// flood limits, no PoW anywhere); registry chains are bounded by the
/// 10MB blob split instead.
pub fn holder_default_open_slot_limit(kind: OpenSlotKind) -> u64 {
match kind {
OpenSlotKind::Greeting => crate::connection::MAX_GREETINGS_PER_BIO,
OpenSlotKind::Registry => u64::MAX,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FoFCommentGating, OpenSlotDecl, PostId};
fn post_with(open_slot: Option<OpenSlotDecl>, policy: Option<CommentTtlPolicy>) -> Post {
Post {
author: [1u8; 32],
content: "p".into(),
attachments: vec![],
timestamp_ms: 0,
fof_gating: open_slot.map(|d| FoFCommentGating {
slot_binder_nonce: [0u8; 32],
pub_post_set: vec![[0u8; 32]; 8],
wrap_slots: vec![],
revocation_list: vec![],
open_slot: Some(d),
}),
supersedes_post_id: None,
comment_ttl: policy,
}
}
fn comment(pub_x_index: Option<u32>) -> InlineComment {
InlineComment {
author: [2u8; 32],
post_id: PostId::default(),
content: String::new(),
timestamp_ms: 1_000,
signature: vec![],
deleted_at: None,
ref_post_id: None,
pub_x_index,
group_sig: None,
encrypted_payload: None,
expires_at_ms: 0,
}
}
fn greeting_slot(idx: u32) -> OpenSlotDecl {
OpenSlotDecl {
slot_index: idx,
kind: OpenSlotKind::Greeting,
body_bucket: 1024,
max_comments: None,
}
}
#[test]
fn class_defaults_match_the_ruling() {
let p = post_with(Some(greeting_slot(3)), None);
// Public comment → never expires.
assert_eq!(rule_for_comment(Some(&p), &comment(None)), CommentTtlRule::Never);
// Post-key-signed (non-open slot) → never expires.
assert_eq!(rule_for_comment(Some(&p), &comment(Some(1))), CommentTtlRule::Never);
// Open slot → randomized 30365d.
assert_eq!(
rule_for_comment(Some(&p), &comment(Some(3))),
CommentTtlRule::Window {
min_ttl_ms: OPEN_SLOT_TTL_MIN_MS,
max_ttl_ms: OPEN_SLOT_TTL_MAX_MS
}
);
}
#[test]
fn author_policy_overrides_every_class() {
let flat = CommentTtlPolicy::flat(30 * 24 * 3600 * 1000);
let p = post_with(Some(greeting_slot(3)), Some(flat));
for idx in [None, Some(1), Some(3)] {
assert_eq!(
rule_for_comment(Some(&p), &comment(idx)),
CommentTtlRule::Window {
min_ttl_ms: flat.min_ttl_ms,
max_ttl_ms: flat.max_ttl_ms
},
"policy must win for every signing class",
);
}
}
#[test]
fn policy_is_clamped_to_the_holder_ceiling() {
let greedy = CommentTtlPolicy { min_ttl_ms: 0, max_ttl_ms: u64::MAX };
let p = post_with(None, Some(greedy));
assert_eq!(
rule_for(Some(&p), CommentClass::Public),
CommentTtlRule::Window { min_ttl_ms: 0, max_ttl_ms: MAX_COMMENT_TTL_MS }
);
}
#[test]
fn ttl_ok_enforces_both_directions() {
let flat = CommentTtlRule::Window {
min_ttl_ms: 30 * 24 * 3600 * 1000,
max_ttl_ms: 30 * 24 * 3600 * 1000,
};
let t = 10_000_000u64;
assert!(ttl_ok(flat, t, t + 30 * 24 * 3600 * 1000));
assert!(!ttl_ok(flat, t, 0), "no-expiry must fail a flat-TTL policy");
assert!(!ttl_ok(flat, t, t + 365 * 24 * 3600 * 1000), "over-long must fail");
assert!(ttl_ok(CommentTtlRule::Never, t, 0));
assert!(!ttl_ok(CommentTtlRule::Never, t, t + 1000), "TTL must fail a Never rule");
}
#[test]
fn draw_expiry_honours_the_never_sentinel() {
assert_eq!(draw_expiry(CommentTtlRule::Never, 5_000), 0);
let w = CommentTtlRule::Window { min_ttl_ms: 100, max_ttl_ms: 100 };
assert_eq!(draw_expiry(w, 5_000), 5_100);
}
/// Ruling / module header: a holder that does not hold the parent
/// post must never grant PERMANENCE on evidence it cannot read.
#[test]
fn unknown_parent_refuses_permanence_but_allows_bounded() {
assert_eq!(
rule_for_comment(None, &comment(None)),
CommentTtlRule::UnknownParent,
"no parent → policy unseen, not the class default",
);
assert_eq!(rule_for_comment(None, &comment(Some(3))), CommentTtlRule::UnknownParent);
let t = 10_000_000u64;
assert!(
!ttl_ok(CommentTtlRule::UnknownParent, t, 0),
"the never-expires sentinel must NOT be honoured on an unheld parent",
);
assert!(
ttl_ok(CommentTtlRule::UnknownParent, t, t + 40 * 24 * 3600 * 1000),
"a bounded TTL self-heals and is accepted",
);
assert!(
!ttl_ok(CommentTtlRule::UnknownParent, t, t + MAX_COMMENT_TTL_MS + TTL_SLACK_MS * 2),
"still capped by the holder ceiling",
);
// Writer side stays optimistic: assume "no policy" = no expiry.
assert_eq!(draw_expiry(CommentTtlRule::UnknownParent, 5_000), 0);
}
/// A slot claim on a post we do not hold is unverifiable — it must
/// not masquerade as evidence of post-key membership.
#[test]
fn slot_claim_on_unheld_parent_is_unverifiable() {
assert_eq!(classify(None, &comment(Some(3))), CommentClass::Unverifiable);
assert_eq!(classify(None, &comment(None)), CommentClass::Public);
let p = post_with(Some(greeting_slot(3)), None);
assert_eq!(classify(Some(&p), &comment(Some(3))), CommentClass::OpenSlot);
assert_eq!(classify(Some(&p), &comment(Some(1))), CommentClass::PostKeySigned);
}
/// The `ITSGOIN_TEST_TTL_SECS` hook was written for the greeting
/// sweep. It must NOT shorten a flat author policy: with the
/// registry's flat 30d shortened to n seconds every registration
/// would expire immediately and the auto-renew check
/// (`renew_registry_entries_if_due`) would be permanently "due",
/// re-registering on every tick. Tested through the pure scope
/// function so no test has to mutate the process environment.
#[test]
fn test_ttl_override_skips_flat_policies() {
let flat = REGISTRATION_FLAT_MS;
assert_eq!(
override_scope(flat, flat, Some(5)), None,
"a flat author policy (registry 30d) ignores the override",
);
assert_eq!(
override_scope(OPEN_SLOT_TTL_MIN_MS, OPEN_SLOT_TTL_MAX_MS, Some(5)),
Some(5),
"the randomized open-slot window is what the hook is for",
);
assert_eq!(
override_scope(OPEN_SLOT_TTL_MIN_MS, OPEN_SLOT_TTL_MAX_MS, None), None,
"no env var, no override",
);
// …and the same scoping governs the holder-side floor relaxation.
let t = 1_000_000u64;
assert!(
!ttl_ok(CommentTtlRule::Window { min_ttl_ms: flat, max_ttl_ms: flat }, t, t + 1000),
"a flat policy keeps its floor in debug builds too",
);
}
const REGISTRATION_FLAT_MS: u64 = 30 * 24 * 3600 * 1000;
#[test]
fn open_slot_limit_clamps_up_but_not_down() {
let mut d = greeting_slot(0);
assert_eq!(open_slot_limit(&d, 64), 64);
d.max_comments = Some(5);
assert_eq!(open_slot_limit(&d, 64), 5, "author may lower the ceiling");
d.max_comments = Some(10_000);
assert_eq!(open_slot_limit(&d, 64), 64, "author may not raise it");
}
}

View file

@ -6212,6 +6212,7 @@ impl ConnectionManager {
let parent = storage.get_post(&payload.post_id).ok().flatten();
if accept_incoming_comment(
&storage,
&payload.post_id,
parent.as_ref(),
&policy,
&followers_set,
@ -9026,11 +9027,16 @@ fn now_ms() -> u64 {
/// Per-bio unexpired greeting cap (holder-side flood limit; round 8:
/// rate caps + size buckets ARE the flood limits, no PoW anywhere).
///
/// v0.8: this is now the holder's DEFAULT ceiling. An author may declare
/// a lower limit in `OpenSlotDecl.max_comments`
/// ([`crate::comment_ttl::open_slot_limit`]); they may never raise it.
pub const MAX_GREETINGS_PER_BIO: u64 = 64;
/// Max accepted TTL: a comment may not claim an expiry more than 366
/// days out (ordinary TTLs top out at 365d).
pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000;
/// days out (open-slot TTLs top out at 365d). Alias of the canonical
/// definition in [`crate::comment_ttl`].
pub use crate::comment_ttl::MAX_COMMENT_TTL_MS;
/// Generous ceiling on a sealed-greeting `encrypted_payload` relative to
/// the declared plaintext bucket: sealed blob (32B eph + 4B len + bucket
@ -9040,30 +9046,55 @@ fn greeting_max_payload_bytes(body_bucket: u16) -> usize {
}
/// The single accept/drop decision for an incoming comment, shared by
/// all three ingest sites. `parent_post` is the locally-held parent (if
/// any); `policy` + `followers_set` are the parent's engagement policy
/// inputs. Returns `true` = store, `false` = drop without forwarding.
/// all three ingest sites. `parent_post_id` is the post the ENVELOPE
/// (header diff / pulled `BlobHeader`) is about and `parent_post` is
/// that post as we hold it (if we do); `policy` + `followers_set` are
/// that post's engagement policy inputs. Returns `true` = store,
/// `false` = drop without forwarding.
///
/// Checks, in order (spec A3 §T9):
/// 0. envelope binding: `comment.post_id` MUST equal `parent_post_id`.
/// Every check below resolves against the ENVELOPE's post while the
/// row is stored under `comment.post_id`; letting the two differ
/// lets an attacker pick a permissive post as the envelope and have
/// its (absent) policy/gating applied to a comment landing on the
/// victim's bio or the registry post;
/// 1. identity signature (digest v2, expiry included);
/// 2. TTL sanity: v0.8 REQUIRES a TTL — `expires_at_ms == 0` rejected,
/// already-expired rejected (stateless re-ingest guard, the
/// comment-analog of `deleted_posts`), >366d rejected;
/// 2. TTL sanity: `0` is the never-expires sentinel; a non-zero expiry
/// must be unexpired (stateless re-ingest guard, the comment-analog
/// of `deleted_posts`) and ≤366d out;
/// 2b. retention: (parent policy × signing class) via
/// [`crate::comment_ttl::ttl_ok`] — rejects in BOTH directions (a
/// never-expires comment on a TTL'd post, an over-long TTL on the
/// stranger channel). An unheld parent accepts bounded TTLs only;
/// 3. policy (blocklist / None / FollowersOnly) + FoF four-check gated
/// on `post.fof_gating.is_some()` — NOT the `CommentPolicy` enum
/// (closing the pre-existing dead-gate gap: nothing ever set the
/// FriendsOfFriends policy, so the enum arm was unreachable);
/// 4. open-slot size buckets + per-bio greeting cap;
/// 4. open-slot size buckets + the author-declared open-slot limit,
/// clamped to the holder's own default for the slot kind;
/// 5. registry-post entry validation + newest-wins upsert.
pub fn accept_incoming_comment(
storage: &crate::storage::Storage,
parent_post_id: &crate::types::PostId,
parent_post: Option<&crate::types::Post>,
policy: &crate::types::CommentPolicy,
followers_set: &HashSet<NodeId>,
comment: &crate::types::InlineComment,
now: u64,
) -> bool {
// 0. Tombstone injection guard: the identity signature does NOT
// 0. Envelope binding. `parent_post`, `policy` and `followers_set`
// all describe `parent_post_id`; `store_comment` files the row under
// `comment.post_id`. If those differ, every decision below is made
// about one post and applied to another — e.g. an ungated public
// post as the envelope (no gating ⇒ no four-check, no open-slot cap,
// no retention policy) carrying a never-expiring comment addressed
// to the victim's bio open slot or to the registry post.
if comment.post_id != *parent_post_id {
return false;
}
// 0b. Tombstone injection guard: the identity signature does NOT
// cover `deleted_at`, so an attacker could take any valid signed
// comment off the chain, set `deleted_at`, and re-send it — an
// unsigned effective delete (store_comment's upsert propagates the
@ -9077,15 +9108,17 @@ pub fn accept_incoming_comment(
return false;
}
// 2. TTL sanity.
if comment.expires_at_ms == 0 {
return false; // v0.8 requires a TTL
}
if comment.expires_at_ms <= now {
return false; // already expired — never (re-)ingest
}
if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) {
return false; // TTL beyond the allowed window
// 2. TTL sanity. `expires_at_ms == 0` is the NEVER-EXPIRES sentinel
// (inside the signed digest, so the author committed to it). Whether
// it is legal here is decided in step 2b against the parent post's
// retention policy — see `crate::comment_ttl`.
if comment.expires_at_ms != 0 {
if comment.expires_at_ms <= now {
return false; // already expired — never (re-)ingest
}
if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) {
return false; // TTL beyond the allowed window
}
}
// Future-dated timestamps: allow only small clock skew. Without this
// a registration ~336d in the future permanently wins newest-wins
@ -9095,6 +9128,21 @@ pub fn accept_incoming_comment(
return false;
}
// 2b. RETENTION: the comment's self-asserted expiry must match the
// rule imposed by (parent post's comment-TTL policy × the comment's
// signing class). Rejects in both directions — a no-expiry comment
// on a TTL'd post (permanent registry squat) and an over-long TTL on
// the stranger channel (permanent squat on someone's greeting
// budget). Writer-side agreement is not enough: the TTL is
// self-asserted, so holders decide.
if !crate::comment_ttl::ttl_ok(
crate::comment_ttl::rule_for_comment(parent_post, comment),
comment.timestamp_ms,
comment.expires_at_ms,
) {
return false;
}
// 3a. Ambient policy checks.
if policy.blocklist.contains(&comment.author) {
return false;
@ -9171,12 +9219,6 @@ pub fn accept_incoming_comment(
if !comment.content.is_empty() {
return false; // greeting bodies are sealed-only
}
let live = storage
.count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index)
.unwrap_or(u64::MAX);
if live >= MAX_GREETINGS_PER_BIO {
return false;
}
}
crate::types::OpenSlotKind::Registry => {
if comment.content.len() > decl.body_bucket as usize {
@ -9184,6 +9226,23 @@ pub fn accept_incoming_comment(
}
}
}
// v0.8: author-declarable LIMIT on live open-slot
// comments, clamped to the holder's own ceiling for the
// kind (authors may lower, never raise — the storage
// being spent is the holder's). Refusal is the separate,
// structural case: no open slot declared at all.
let limit = crate::comment_ttl::open_slot_limit(
decl,
crate::comment_ttl::holder_default_open_slot_limit(decl.kind),
);
if limit != u64::MAX {
let live = storage
.count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index)
.unwrap_or(u64::MAX);
if live >= limit {
return false;
}
}
}
}
}
@ -9193,9 +9252,11 @@ pub fn accept_incoming_comment(
if crate::registry::parse_registration(&comment.content).is_err() {
return false;
}
if !crate::registry::registration_ttl_ok(comment.timestamp_ms, comment.expires_at_ms) {
return false;
}
// TTL is NOT re-checked here: step 2b already validated it
// against the registry post's own `comment_ttl` policy (flat
// 30d), which covers every comment on the post — registrations,
// duplicate-reports, anything — not just the ones that parse as
// registration entries.
// Newest-wins per persona: READ-ONLY check here — drop the
// incoming comment when a newer entry is already stored. The
// destructive half (hard-deleting the older row) happens in the
@ -9254,7 +9315,15 @@ pub fn ingest_header_comments(
.collect();
let mut stored = 0usize;
for comment in &header.comments {
if accept_incoming_comment(storage, parent.as_ref(), &policy, &followers, comment, now) {
if accept_incoming_comment(
storage,
&header.post_id,
parent.as_ref(),
&policy,
&followers,
comment,
now,
) {
if storage.store_comment(comment).is_ok() {
stored += 1;
// Registry newest-wins pruning only after a SUCCESSFUL
@ -9315,6 +9384,16 @@ mod accept_gate_tests {
/// Storage + a stored bio post with a Greeting open slot, owned by
/// a persona with a V_me. Returns (storage, bio_post_id, bio_post).
fn setup_greeting_bio(seed_byte: u8) -> (Storage, PostId, Post) {
setup_greeting_bio_with(seed_byte, None, None)
}
/// As above, plus the author-declared open-slot LIMIT and an
/// optional per-post comment-retention policy.
fn setup_greeting_bio_with(
seed_byte: u8,
max_comments: Option<u32>,
comment_ttl: Option<crate::types::CommentTtlPolicy>,
) -> (Storage, PostId, Post) {
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(seed_byte);
s.upsert_posting_identity(&PostingIdentity {
@ -9328,7 +9407,10 @@ mod accept_gate_tests {
s.insert_own_vouch_key(&alice_id, 1, &v_me, 1000).unwrap();
let built = crate::fof::build_fof_comment_gating(
&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)),
&s,
&alice_id,
Some(crate::fof::OpenSlotSpec::new(OpenSlotKind::Greeting, 1024)
.with_limit(max_comments)),
).unwrap().expect("gating built");
let post = Post {
author: alice_id,
@ -9337,6 +9419,7 @@ mod accept_gate_tests {
timestamp_ms: 3000,
fof_gating: Some(built.gating),
supersedes_post_id: None,
comment_ttl,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_intent(
@ -9369,8 +9452,28 @@ mod accept_gate_tests {
).unwrap()
}
/// Envelope == the post the comment names (the honest case).
fn gate(s: &Storage, post: Option<&Post>, c: &InlineComment) -> bool {
accept_incoming_comment(s, post, &CommentPolicy::default(), &HashSet::new(), c, now())
gate_with_envelope(s, &c.post_id, post, c)
}
/// Explicit envelope post id — lets a test offer a comment under a
/// DIFFERENT post than the one it names.
fn gate_with_envelope(
s: &Storage,
envelope_id: &PostId,
post: Option<&Post>,
c: &InlineComment,
) -> bool {
accept_incoming_comment(
s,
envelope_id,
post,
&CommentPolicy::default(),
&HashSet::new(),
c,
now(),
)
}
/// Pull-path variant: wrap the comment in a BlobHeader and run the
@ -9504,6 +9607,95 @@ mod accept_gate_tests {
assert!(!gate(&s, None, &c));
}
/// The retention rule, the FoF four-check, the size bucket and the
/// open-slot limit all resolve against the ENVELOPE's post, while
/// the row is filed under `comment.post_id`. Offering a comment
/// under a different post than it names must be rejected outright:
/// an ungated public post as the envelope has no gating, no policy
/// and no open-slot cap, which would otherwise buy a PERMANENT
/// squat on the victim's bio greeting slot (or the registry).
#[test]
fn comment_naming_a_different_post_than_the_envelope_rejected() {
let (s, bio_id, bio) = setup_greeting_bio(17);
let decoy = Post {
author: bio.author,
content: "an ordinary ungated post".into(),
attachments: vec![],
timestamp_ms: 4000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let decoy_id = crate::content::compute_post_id(&decoy);
s.store_post_with_intent(
&decoy_id, &decoy, &PostVisibility::Public, &VisibilityIntent::Public,
).unwrap();
// A NEVER-EXPIRING greeting addressed to the bio's open slot…
let c = make_greeting(&bio, &bio_id, 59, now(), 0);
// …smuggled in under the decoy envelope.
assert!(
!gate_with_envelope(&s, &decoy_id, Some(&decoy), &c),
"diff path: the comment must name the envelope's post",
);
let header = BlobHeader {
post_id: decoy_id,
author: decoy.author,
reactions: vec![],
comments: vec![c.clone()],
policy: CommentPolicy::default(),
updated_at: now(),
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
prior_author: None,
};
assert_eq!(
ingest_header_comments(&s, &header, now()), 0,
"pull path: the comment must name the envelope's post",
);
assert!(
s.get_comments_with_tombstones(&bio_id).unwrap().is_empty(),
"nothing may land on the bio through another post's envelope",
);
// Sanity: the honest envelope + the ruling's TTL still works.
let ok = make_greeting(&bio, &bio_id, 60, now(), now() + 40 * 24 * 3600 * 1000);
assert!(gate(&s, Some(&bio), &ok), "honest greeting still accepted");
}
/// Permanence is never granted on evidence we cannot read: a holder
/// that does not hold the parent post has never seen its retention
/// policy, so it stores bounded TTLs (they self-heal) but refuses
/// the `0` never-expires sentinel.
#[test]
fn never_expires_comment_on_an_unheld_parent_rejected() {
let (_s, _post, post_id, forever) = public_post_and_comment(94, 0);
let holder = temp_storage(); // does NOT hold the parent post
let hdr = |c: &InlineComment| BlobHeader {
post_id,
author: c.author,
reactions: vec![],
comments: vec![c.clone()],
policy: CommentPolicy::default(),
updated_at: now(),
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
prior_author: None,
};
assert!(!gate(&holder, None, &forever), "diff path: no permanence on an unseen policy");
assert_eq!(ingest_header_comments(&holder, &hdr(&forever), now()), 0, "pull path too");
let t = now();
let (_s2, _p2, _id2, bounded) = public_post_and_comment(94, t + 40 * 24 * 3600 * 1000);
assert!(
gate(&holder, None, &bounded),
"a bounded TTL under an unheld parent is accepted — it self-heals",
);
}
/// Registration matrix: valid entry accepted; wrong TTL rejected;
/// bad shape rejected; older-than-stored dropped — both paths.
#[test]
@ -9581,6 +9773,277 @@ mod accept_gate_tests {
assert_eq!(rows[0].timestamp_ms, t + 1000);
}
// ---- v0.8 comment-retention taxonomy (ruling 2026-08-03) ----
//
// Retention = (parent post's comment-TTL policy) × (comment's
// signing class). Public and post-key-signed comments never expire
// (`expires_at_ms == 0` sentinel); the open-slot stranger channel is
// the only class with an automatic TTL; an author policy (registry
// posts: flat 30d) overrides every class.
/// A plain public post + a plain public comment on it, signed under
/// the commenter's real posting key (no slot claim).
fn public_post_and_comment(
seed_byte: u8,
expires_at_ms: u64,
) -> (Storage, Post, PostId, InlineComment) {
let s = temp_storage();
let (author_id, _) = make_persona(seed_byte);
let post = Post {
author: author_id,
content: "hello".into(),
attachments: vec![],
timestamp_ms: 3000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_intent(
&post_id, &post, &PostVisibility::Public, &VisibilityIntent::Public,
).unwrap();
let (commenter, commenter_seed) = make_persona(seed_byte.wrapping_add(1));
let ts = now();
let content = "nice post";
let signature = crate::crypto::sign_comment(
&commenter_seed, &commenter, &post_id, content, ts, None, expires_at_ms,
);
let c = InlineComment {
author: commenter,
post_id,
content: content.into(),
timestamp_ms: ts,
signature,
deleted_at: None,
ref_post_id: None,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms,
};
(s, post, post_id, c)
}
/// Ruling #2: public comments never auto-expire — the `0` sentinel
/// is accepted on both ingest paths AND survives a sweep run far
/// past any TTL that would previously have been drawn for it.
#[test]
fn public_comment_has_no_expiry_and_survives_the_sweep() {
let (s, post, post_id, c) = public_post_and_comment(90, 0);
assert!(gate(&s, Some(&post), &c), "no-expiry public comment accepted (diff)");
assert!(gate_via_pull(&s, &post_id, &post.author, &c), "…and on the pull path");
// Sweep a full year past the old 30365d TTL window.
let far_future = now() + 400 * 24 * 3600 * 1000;
s.expire_comments(far_future).unwrap();
assert_eq!(
s.get_comments(&post_id).unwrap().len(),
1,
"a never-expiring comment must survive any sweep",
);
}
/// Holder-side enforcement, the "no auto-expiry" direction: a public
/// comment that self-assigns a TTL contradicts its parent's rule and
/// is dropped (the taxonomy is deterministic — divergence is a bug
/// or an attack, never a preference).
#[test]
fn public_comment_with_self_assigned_ttl_rejected() {
let t = now();
let (s, post, post_id, c) = public_post_and_comment(92, t + 40 * 24 * 3600 * 1000);
assert!(!gate(&s, Some(&post), &c));
assert!(!gate_via_pull(&s, &post_id, &post.author, &c));
}
/// Ruling #3: a post-key-signed (FoF/member) comment carries the `0`
/// sentinel and is accepted; the same comment with a TTL is not.
#[test]
fn post_key_signed_comment_has_no_expiry() {
let (s, post_id, post) = setup_greeting_bio(93);
// Alice's own V_me unlocks one of the REAL (non-open) slots.
let unlock = crate::fof::find_unlock_for_post(&s, &post).unwrap().expect("member unlock");
let open_idx = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index;
assert_ne!(unlock.slot_index, open_idx, "member slot, not the open slot");
let identity = s.get_posting_identity(&unlock.persona_id).unwrap().unwrap();
let nonce = post.fof_gating.as_ref().unwrap().slot_binder_nonce;
let t = now();
let never = crate::fof::build_fof_comment(
&post_id, &unlock, &nonce, &identity.node_id, &identity.secret_seed,
"members only", None, t, 0,
).unwrap();
assert!(gate(&s, Some(&post), &never), "member comment never expires");
assert!(gate_via_pull(&s, &post_id, &post.author, &never));
let ttl_d = crate::fof::build_fof_comment(
&post_id, &unlock, &nonce, &identity.node_id, &identity.secret_seed,
"members only", None, t + 1, t + 40 * 24 * 3600 * 1000,
).unwrap();
assert!(!gate(&s, Some(&post), &ttl_d), "a member comment may not self-assign a TTL");
}
/// Ruling #4: the open-slot stranger channel keeps the randomized
/// 30365d TTL, and a self-assigned over-long TTL is rejected even
/// when it is under the absolute 366-day holder ceiling.
#[test]
fn greeting_ttl_window_enforced_holder_side() {
let (s, post_id, post) = setup_greeting_bio(94);
let t = now();
let day = 24 * 3600 * 1000u64;
// Inside the window: fine.
let ok = make_greeting(&post, &post_id, 95, t, t + 200 * day);
assert!(gate(&s, Some(&post), &ok));
// Over-long but under MAX_COMMENT_TTL_MS (366d) → the POLICY
// window is what rejects it, not the absolute ceiling.
let long = make_greeting(&post, &post_id, 96, t, t + 365 * day + 6 * 3600 * 1000);
assert!(long.expires_at_ms < t + MAX_COMMENT_TTL_MS, "under the absolute ceiling");
assert!(!gate(&s, Some(&post), &long), "over-long greeting TTL rejected");
assert!(!gate_via_pull(&s, &post_id, &post.author, &long));
// Too short (a 1-hour self-retiring greeting is also a mismatch).
let short = make_greeting(&post, &post_id, 97, t, t + 3600 * 1000);
assert!(!gate(&s, Some(&post), &short));
}
/// Ruling #1, holder-side enforcement, the other direction: a
/// never-expires comment submitted to a post whose policy demands a
/// TTL (the registry post: flat 30d) is dropped. Without this a
/// spammer buys a permanent registry slot.
#[test]
fn no_expiry_comment_on_registry_post_rejected() {
let s = temp_storage();
crate::registry::materialize_registry_post(&s).unwrap();
let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap();
let (persona_id, persona_seed) = make_persona(98);
let t = now();
let content = r#"{"v":1,"name":"Mallory","keywords":[]}"#;
let build = |expires: u64| -> InlineComment {
let unlock = crate::fof::derive_open_slot_unlock(&registry, &persona_id).unwrap();
let group_sig = {
use ed25519_dalek::Signer;
let signer = SigningKey::from_bytes(&unlock.priv_x_seed);
let mut to_sign = Vec::new();
to_sign.extend_from_slice(content.as_bytes());
to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID);
to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes());
signer.sign(&to_sign).to_bytes().to_vec()
};
let signature = crate::crypto::sign_comment(
&persona_seed, &persona_id, &crate::registry::REGISTRY_POST_ID,
content, t, None, expires,
);
InlineComment {
author: persona_id,
post_id: crate::registry::REGISTRY_POST_ID,
content: content.into(),
timestamp_ms: t,
signature,
deleted_at: None,
ref_post_id: None,
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: None,
expires_at_ms: expires,
}
};
let never = build(0);
assert!(!gate(&s, Some(&registry), &never), "no-expiry rejected on a TTL'd post");
assert!(!gate_via_pull(
&s, &crate::registry::REGISTRY_POST_ID, &registry.author, &never,
));
// Sanity: the same comment with the post's flat 30d is fine.
let ok = build(t + crate::registry::REGISTRATION_TTL_MS);
assert!(gate(&s, Some(&registry), &ok));
}
/// Ruling #1, the part the shipped code got wrong: the 30-day fuse
/// belongs to the POST, not to "registrations". ANY comment on a
/// registry post — a duplicate-report, anything a future release
/// adds — resolves to the same flat-30d rule, and it does so through
/// the generic per-post policy, with no `is_registry_post` branch.
#[test]
fn every_comment_class_on_a_registry_post_gets_the_flat_30d_rule() {
use crate::comment_ttl::{rule_for, CommentClass, CommentTtlRule};
let s = temp_storage();
crate::registry::materialize_registry_post(&s).unwrap();
let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap();
let expected = CommentTtlRule::Window {
min_ttl_ms: crate::registry::REGISTRATION_TTL_MS,
max_ttl_ms: crate::registry::REGISTRATION_TTL_MS,
};
for class in [CommentClass::Public, CommentClass::PostKeySigned, CommentClass::OpenSlot] {
assert_eq!(rule_for(Some(&registry), class), expected, "{:?}", class);
}
// And it really is the post's own field driving this.
assert_eq!(registry.comment_ttl, Some(crate::registry::REGISTRY_COMMENT_TTL_POLICY));
}
/// Ruling #6: the author-declared open-slot LIMIT is enforced
/// holder-side, below the holder's own default ceiling.
#[test]
fn author_declared_open_slot_limit_enforced() {
let (s, post_id, post) = setup_greeting_bio_with(99, Some(2), None);
let t = now();
let slot = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap();
assert_eq!(slot.max_comments, Some(2), "limit rides the signed declaration");
let slot_index = slot.slot_index;
for i in 0..2u64 {
let c = make_greeting(&post, &post_id, 100 + i as u8, t + i, t + 40 * 24 * 3600 * 1000);
assert!(gate(&s, Some(&post), &c), "under the author's limit");
s.store_comment(&c).unwrap();
}
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, slot_index).unwrap(), 2);
let over = make_greeting(&post, &post_id, 110, t + 2, t + 40 * 24 * 3600 * 1000);
assert!(!gate(&s, Some(&post), &over), "author's limit enforced (diff)");
assert!(!gate_via_pull(&s, &post_id, &post.author, &over), "…and on the pull path");
}
/// An author cannot RAISE the ceiling past the holder's own default:
/// the storage being spent is the holder's.
#[test]
fn author_cannot_raise_the_open_slot_ceiling() {
let (_s, _post_id, post) = setup_greeting_bio_with(111, Some(100_000), None);
let decl = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap();
assert_eq!(
crate::comment_ttl::open_slot_limit(
decl,
crate::comment_ttl::holder_default_open_slot_limit(decl.kind),
),
MAX_GREETINGS_PER_BIO,
);
}
/// An author-set policy on an ORDINARY post works exactly like the
/// registry's — this is the forward path for a future "comments on
/// this post expire in N days" composer option, with no new
/// mechanism: set `Post.comment_ttl` and every holder enforces it.
#[test]
fn author_set_policy_on_an_ordinary_post_is_enforced() {
let day = 24 * 3600 * 1000u64;
let (s, post_id, post) = setup_greeting_bio_with(
112, None, Some(crate::types::CommentTtlPolicy::flat(7 * day)),
);
let t = now();
let matching = make_greeting(&post, &post_id, 113, t, t + 7 * day);
assert!(gate(&s, Some(&post), &matching), "matches the author's 7d policy");
// The class default (30365d) no longer applies — the post's own
// policy wins for every class.
let class_default = make_greeting(&post, &post_id, 114, t, t + 200 * day);
assert!(!gate(&s, Some(&post), &class_default));
let never = make_greeting(&post, &post_id, 115, t, 0);
assert!(!gate(&s, Some(&post), &never));
}
#[test]
fn delete_comment_authorization_matrix() {
let (author_id, author_seed) = make_persona(80);
@ -9662,6 +10125,7 @@ mod accept_gate_tests {
timestamp_ms: old_bio.timestamp_ms + 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let new_bio_id = crate::content::compute_post_id(&new_bio);
s.store_post_with_intent(

View file

@ -25,6 +25,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id1 = compute_post_id(&post);
let id2 = compute_post_id(&post);
@ -40,6 +41,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post2 = Post {
author: [1u8; 32],
@ -48,6 +50,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
}
@ -61,6 +64,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id = compute_post_id(&post);
assert!(verify_post_id(&id, &post));

View file

@ -166,6 +166,7 @@ pub fn build_delete_control_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -195,6 +196,7 @@ pub fn build_visibility_control_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -227,6 +229,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
@ -257,6 +260,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();

View file

@ -53,6 +53,13 @@ pub struct ExportedPost {
pub visibility_json: String,
pub header_json: Option<String>,
pub intent: Option<String>,
/// v0.8: the post's comment-retention policy
/// (`crate::comment_ttl`). It is INSIDE `PostId = BLAKE3(Post)`, so
/// an export that drops it produces an archive whose posts fail
/// `verify_post_id` on import and are skipped as forged. Absent in
/// pre-v0.8 archives → `None`, which is what those posts carried.
#[serde(default)]
pub comment_ttl_json: Option<String>,
}
/// Result of an export operation.
@ -280,6 +287,10 @@ async fn gather_posts(
visibility_json: serde_json::to_string(vis).unwrap_or_default(),
header_json: header,
intent,
comment_ttl_json: post
.comment_ttl
.as_ref()
.and_then(|p| serde_json::to_string(p).ok()),
});
// Collect blob CIDs from attachments

View file

@ -37,16 +37,40 @@ pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
///
/// Side effect: this function is pure; no storage writes. The caller
/// owns persisting the resulting Post.
/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is
/// `open_slot`: when `Some(spec)`, one EXTRA real slot is
/// sealed under the derivable `V_open =
/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in
/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots).
/// The open slot is a REAL wrap slot sealed by the normal path; what
/// makes it open is only that its V_x is derivable by anyone.
/// `None` = the post REFUSES stranger comments entirely (structural
/// refusal); `spec.max_comments` is the softer author-declared LIMIT.
/// What kind of open slot to attach at publish time, and the author's
/// optional LIMIT on how many live comments it will accept. Mirrors
/// [`OpenSlotDecl`] minus `slot_index` (assigned by the shuffle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenSlotSpec {
pub kind: OpenSlotKind,
pub body_bucket: u16,
/// `None` = holder default for the kind. See
/// [`crate::comment_ttl::open_slot_limit`].
pub max_comments: Option<u32>,
}
impl OpenSlotSpec {
pub const fn new(kind: OpenSlotKind, body_bucket: u16) -> Self {
Self { kind, body_bucket, max_comments: None }
}
pub const fn with_limit(mut self, max_comments: Option<u32>) -> Self {
self.max_comments = max_comments;
self
}
}
pub fn build_fof_comment_gating(
storage: &Storage,
author_persona_id: &NodeId,
open_slot: Option<(OpenSlotKind, u16)>,
open_slot: Option<OpenSlotSpec>,
) -> Result<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// The author's own V_me appears with owner=author_persona_id.
@ -169,10 +193,11 @@ pub fn build_fof_comment_gating(
}
let open_slot_decl = match (open_slot, open_slot_index) {
(Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl {
(Some(spec), Some(slot_index)) => Some(OpenSlotDecl {
slot_index,
kind,
body_bucket,
kind: spec.kind,
body_bucket: spec.body_bucket,
max_comments: spec.max_comments,
}),
_ => None,
};
@ -662,6 +687,12 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
MAX_OPEN_SLOT_BODY_BUCKET,
);
}
// v0.8: an author-declared limit of 0 is not "refuse" — refusal
// is declaring no open slot at all. A zero here is malformed
// (and would be an invisible black hole for senders).
if decl.max_comments == Some(0) {
anyhow::bail!("open_slot.max_comments = 0 — omit the open slot to refuse instead");
}
}
Ok(())
@ -1222,6 +1253,7 @@ mod tests {
timestamp_ms: 3000,
fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Bob's device unlocks the post via his V_me (= v_x_bob).
@ -1305,6 +1337,7 @@ mod tests {
author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1396,6 +1429,7 @@ mod tests {
author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1498,6 +1532,7 @@ mod tests {
author: alice_id, content: "x".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1589,7 +1624,7 @@ mod tests {
fn dummy_post(g: Option<crate::types::FoFCommentGating>) -> crate::types::Post {
crate::types::Post {
author: [0u8; 32], content: String::new(), attachments: vec![],
timestamp_ms: 0, fof_gating: g, supersedes_post_id: None,
timestamp_ms: 0, fof_gating: g, supersedes_post_id: None, comment_ttl: None,
}
}
@ -1694,6 +1729,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1825,6 +1861,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Bob's first scan — full scan path, populates cache.
@ -1882,6 +1919,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Persist the post so the sweep can re-fetch it.
s.store_post_with_intent(
@ -1957,6 +1995,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
.expect("Bob can unlock");
@ -2098,7 +2137,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)))
let built = build_fof_comment_gating(&s, &alice_id, Some(OpenSlotSpec::new(OpenSlotKind::Greeting, 1024)))
.unwrap().expect("built");
let decl = built.gating.open_slot.as_ref().expect("open slot declared");
assert_eq!(decl.kind, OpenSlotKind::Greeting);
@ -2110,6 +2149,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Wire-shape validation passes.
@ -2143,27 +2183,39 @@ mod tests {
// slot_index out of bounds.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: None });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of bounds"), "got: {}", err);
// body_bucket too big.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 });
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097, max_comments: None });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of range"), "got: {}", err);
// body_bucket zero.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 });
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0, max_comments: None });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes.
// v0.8: max_comments = 0 is malformed — refusal is declaring no
// open slot at all, not an unreachable one.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: Some(0) });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes, with and without an author LIMIT.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: None });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: Some(5) });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
}

View file

@ -57,6 +57,7 @@ pub fn build_distribution_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = compute_post_id(&post);
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
@ -239,6 +240,7 @@ mod tests {
timestamp_ms: 200,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

View file

@ -291,6 +291,13 @@ pub async fn import_as_personas(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Preserve the original visibility intent from the export.
@ -466,6 +473,13 @@ pub async fn import_public_posts(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Read blob data from archive
@ -704,6 +718,13 @@ pub async fn merge_with_key(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Read blob data from archive (may need decryption for encrypted posts)

View file

@ -2,6 +2,7 @@ pub mod activity;
#[cfg(target_os = "android")]
pub mod android_wifi;
pub mod blob;
pub mod comment_ttl;
pub mod connection;
pub mod content;
pub mod control;

View file

@ -2417,6 +2417,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}

View file

@ -124,6 +124,36 @@ fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId
.unwrap_or(true)
}
/// v0.8: per-persona LIMIT on how many live stranger greetings the bio's
/// open slot will accept. Unset = the holder default
/// (`MAX_GREETINGS_PER_BIO`). The value is baked into the bio post's
/// `OpenSlotDecl` at publish time, so every holder enforces it — the
/// author cannot change it after the fact without republishing the bio.
/// No UI yet; the mechanism and plumbing are here for it.
pub fn greetings_max_setting_key(posting_id: &NodeId) -> String {
format!("greetings_max.{}", hex::encode(posting_id))
}
fn greetings_max_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> Option<u32> {
storage
.get_setting(&greetings_max_setting_key(posting_id))
.ok()
.flatten()
.and_then(|v| v.parse::<u32>().ok())
// 0 is not "refuse" — refusal is `greetings_open = 0`, which
// publishes a bio with no open slot at all.
.filter(|n| *n > 0)
}
/// Build the bio's Greeting open-slot spec from the persona's settings.
fn greeting_open_slot_spec(
storage: &crate::storage::Storage,
posting_id: &NodeId,
) -> crate::fof::OpenSlotSpec {
crate::fof::OpenSlotSpec::new(crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)
.with_limit(greetings_max_setting(storage, posting_id))
}
/// v0.8 (A3): persist the author-side state of a freshly-published
/// gated post: slot provenance (cascade revocation), cached CEK
/// (author-direct decrypt), and the FriendsOfFriends comment policy.
@ -1120,7 +1150,7 @@ impl Node {
crate::fof::build_fof_comment_gating(
&*storage,
posting_id,
Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)),
Some(greeting_open_slot_spec(&storage, posting_id)),
)?
} else {
None
@ -1217,6 +1247,7 @@ impl Node {
timestamp_ms: pi.created_at,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
{
@ -1574,6 +1605,7 @@ impl Node {
timestamp_ms: now,
fof_gating: Some(built.gating),
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
@ -1734,6 +1766,7 @@ impl Node {
timestamp_ms: now,
fof_gating,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = compute_post_id(&post);
@ -2143,7 +2176,7 @@ impl Node {
crate::fof::build_fof_comment_gating(
&*storage,
&posting_id,
Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)),
Some(greeting_open_slot_spec(&storage, &posting_id)),
)?
} else {
None
@ -3854,6 +3887,7 @@ impl Node {
timestamp_ms: post.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let new_post_id = compute_post_id(&new_post);
@ -5275,8 +5309,22 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
// v0.8 (A3): TTL drawn BEFORE signing — it's inside the digest.
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
// v0.8: TTL drawn BEFORE signing — it's inside the digest.
// Ordinary public comment: NO expiry unless the parent post
// declares a retention policy (registry posts do; a future
// author-set TTL would land in the same field).
let expires_at_ms = {
let storage = self.storage.get().await;
let parent = storage.get_post(&post_id).ok().flatten();
drop(storage);
crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
parent.as_ref(),
crate::comment_ttl::CommentClass::Public,
),
now,
)
};
let signature = crate::crypto::sign_comment(
&seed,
&our_node_id,
@ -5544,7 +5592,7 @@ impl Node {
post_id: PostId,
body: String,
) -> anyhow::Result<crate::types::InlineComment> {
let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author) = {
let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author, ttl_rule) = {
let storage = self.storage.get().await;
let post = storage.get_post(&post_id)?
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
@ -5555,7 +5603,13 @@ impl Node {
.ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?;
let identity = storage.get_posting_identity(&unlock.persona_id)?
.ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?;
(unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author)
// Post-key-signed (member) comment: NO expiry unless the
// post itself declares a retention policy.
let rule = crate::comment_ttl::rule_for(
Some(&post),
crate::comment_ttl::CommentClass::PostKeySigned,
);
(unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author, rule)
};
let now = std::time::SystemTime::now()
@ -5563,7 +5617,7 @@ impl Node {
.as_millis() as u64;
// v0.8 (A3): expiry rides the outer plaintext fields so
// non-member holders can expire the comment too.
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
let expires_at_ms = crate::comment_ttl::draw_expiry(ttl_rule, now);
let comment = crate::fof::build_fof_comment(
&post_id, &unlock, &slot_binder_nonce,
&commenter_id, &commenter_secret, &body, None, now, expires_at_ms,
@ -6295,6 +6349,52 @@ impl Node {
Ok(greetings_open_setting(&s, posting_id))
}
/// Ruling #6: the author-declarable LIMIT on live stranger greetings
/// for this persona's bio. `None` (or 0) = the holder default
/// ([`crate::connection::MAX_GREETINGS_PER_BIO`]); refusal is the
/// separate, structural case ([`Self::set_greetings_open`] with
/// `false`, which publishes a bio with no open slot at all).
///
/// The limit is baked into the bio post's signed `OpenSlotDecl`, so
/// — exactly like the consent flag — it only reaches holders when
/// the bio is republished. Writing the setting alone (e.g. through
/// the generic `set_setting` command) would have no observable
/// effect until some unrelated republish happened to pick it up.
pub async fn set_greetings_max(
&self,
posting_id: &NodeId,
max_comments: Option<u32>,
) -> anyhow::Result<()> {
let (secret, display_name, bio, avatar) = {
let s = self.storage.get().await;
let key = greetings_max_setting_key(posting_id);
match max_comments.filter(|n| *n > 0) {
Some(n) => s.set_setting(&key, &n.to_string())?,
None => s.set_setting(&key, "")?,
}
let identity = s
.get_posting_identity(posting_id)?
.ok_or_else(|| anyhow::anyhow!("persona not on this device"))?;
let profile = s.get_profile(posting_id)?;
(
identity.secret_seed,
profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(),
profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(),
profile.as_ref().and_then(|p| p.avatar_cid),
)
};
// Republish so the new `OpenSlotDecl.max_comments` enters the
// signed post every holder enforces.
self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?;
Ok(())
}
/// Current author-declared greeting limit (`None` = holder default).
pub async fn get_greetings_max(&self, posting_id: &NodeId) -> anyhow::Result<Option<u32>> {
let s = self.storage.get().await;
Ok(greetings_max_setting(&s, posting_id))
}
/// Best-effort network fetch of a post we don't hold: content-search
/// worm by post id, then PostFetch from the reported holders, stored
/// through the standard receive path.
@ -6426,7 +6526,16 @@ impl Node {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
// Open-slot stranger channel: the ONE class with an automatic
// TTL — randomized 30365d throwaway-ID retirement, unless the
// post author declared their own retention policy.
let expires_at_ms = crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
Some(&target_post),
crate::comment_ttl::CommentClass::OpenSlot,
),
now,
);
let comment = crate::fof::build_fof_comment(
&target_post_id,
&unlock,
@ -6640,7 +6749,17 @@ impl Node {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let expires_at_ms = now + crate::registry::REGISTRATION_TTL_MS;
// The 30-day registration TTL now comes FROM THE POST's own
// retention policy (flat 30d), not from a registration-specific
// constant at the call site — same path a future author-set TTL
// would take.
let expires_at_ms = crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
Some(&registry_post),
crate::comment_ttl::CommentClass::OpenSlot,
),
now,
);
// group_sig over (content_bytes || post_id || pub_x_index_le) —
// the plaintext-open-slot form of the standard scheme.
@ -6793,6 +6912,21 @@ impl Node {
let due: Vec<(NodeId, String, Vec<String>)> = {
let s = self.storage.get().await;
// What retention does the registry post itself impose on its
// comments? Under a Window rule (the flat 30d) a stored
// expiry of 0 is not a legal state — `get_newest_registry_entry`
// maps a NULL `expires_at` column to 0, so it cannot tell
// "never expires" from "no expiry recorded". Treating such a
// row as never-renew would silently drop the persona off the
// registry: remote holders still enforce the 30 days and
// delete the entry, while our local DB shows it as listed
// forever. Only a `Never` rule makes 0 mean never.
let registry_rule = crate::comment_ttl::rule_for(
s.get_post(&crate::registry::REGISTRY_POST_ID)?.as_ref(),
crate::comment_ttl::CommentClass::OpenSlot,
);
let zero_expiry_is_permanent =
matches!(registry_rule, crate::comment_ttl::CommentTtlRule::Never);
let mut due = Vec::new();
for persona in s.list_posting_identities()? {
let id_hex = hex::encode(persona.node_id);
@ -6808,6 +6942,11 @@ impl Node {
&persona.node_id,
)?;
let needs_renew = match newest {
// `exp == 0`: never-expires sentinel ONLY when the
// registry post declares no TTL policy (a future
// shard could). Under a Window rule it means the row
// has no expiry recorded — renew it.
Some((_ts, 0)) => !zero_expiry_is_permanent,
Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS,
None => true,
};

View file

@ -208,6 +208,7 @@ pub fn build_profile_post(
timestamp_ms,
fof_gating,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -478,6 +479,7 @@ mod tests {
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
// Apply. Auto-scan should fire and store the unwrapped V_me.
@ -548,6 +550,7 @@ mod tests {
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();

View file

@ -48,30 +48,19 @@ pub const MAX_REGISTRY_NAME_CHARS: usize = 64;
pub const MAX_REGISTRY_KEYWORDS: usize = 8;
pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32;
/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE
/// signing (the expiry is inside the signed digest).
pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// The registry post's comment-retention policy: a FLAT 30 days for
/// EVERY comment on it — registrations, duplicate-reports, anything.
/// It is carried in the post's own `comment_ttl` field, i.e. through the
/// same generic mechanism a future author-set TTL will use; nothing
/// downstream branches on "is this the registry post?".
pub const REGISTRY_COMMENT_TTL_POLICY: crate::types::CommentTtlPolicy =
crate::types::CommentTtlPolicy::flat(REGISTRATION_TTL_MS);
/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`.
/// The randomness serves identity hygiene — throwaway commenter IDs
/// vanish at unpredictable times.
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` makes every ordinary
/// comment expire in n seconds (integration-test hook; debug builds only).
pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 {
#[cfg(debug_assertions)]
{
if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") {
if let Ok(secs) = secs.parse::<u64>() {
return now_ms + secs * 1000;
}
}
}
use rand::Rng;
let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS);
now_ms + ttl
}
/// Open-slot stranger-channel TTL window: rand(30..=365 days).
/// Retained as aliases; the definitions live in [`crate::comment_ttl`].
pub use crate::comment_ttl::{
OPEN_SLOT_TTL_MAX_MS as ORDINARY_TTL_MAX_MS, OPEN_SLOT_TTL_MIN_MS as ORDINARY_TTL_MIN_MS,
};
/// The frozen canonical serialized bytes of the registry post. Generated
/// once (see `build_canonical_registry_post` + the `frozen_bytes_*`
@ -81,10 +70,10 @@ pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json");
/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted
/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`).
pub const REGISTRY_POST_ID: PostId = [
0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc,
0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d,
0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf,
0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4,
0x10, 0xa1, 0xbe, 0x33, 0x83, 0xef, 0xb2, 0x97,
0x76, 0x07, 0xfe, 0x45, 0xc4, 0xa7, 0xb3, 0xf1,
0xb5, 0xe6, 0x26, 0xe8, 0x1d, 0x0a, 0xc1, 0xaf,
0x9c, 0x0f, 0x3d, 0x7e, 0xb9, 0x86, 0x4d, 0x32,
];
/// Deterministic builder for the canonical registry post. All seal
@ -122,6 +111,9 @@ pub fn build_canonical_registry_post() -> Result<Post> {
slot_index: 0,
kind: OpenSlotKind::Registry,
body_bucket: REGISTRY_BODY_BUCKET,
// Registry chains are bounded by the 10MB blob split, not a
// count cap (design: family auto-split).
max_comments: None,
}),
};
@ -138,6 +130,10 @@ pub fn build_canonical_registry_post() -> Result<Post> {
timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS,
fof_gating: Some(gating),
supersedes_post_id: None,
// The 30-day retention for EVERY comment on this post rides the
// generic per-post policy field — no registry special case
// anywhere downstream.
comment_ttl: Some(REGISTRY_COMMENT_TTL_POLICY),
})
}
@ -198,9 +194,26 @@ pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
/// Fixed-30d TTL check for registrations: `expires_at_ms timestamp_ms`
/// must equal 30 days within ±5 minutes of slack.
///
/// This is now just the registry specialization of the GENERIC
/// [`crate::comment_ttl::ttl_ok`] — the ingest gate calls the generic
/// form against whatever policy the parent post declares, so registry
/// comments that are not registrations are covered by the same rule.
///
/// The `ITSGOIN_TEST_TTL_SECS` debug override does NOT apply here: it is
/// scoped to randomized windows (`min < max`, i.e. the open-slot
/// greeting channel), and the registration window is flat. A flat policy
/// is a correctness constraint holders enforce exactly, not a duration
/// knob — so this bound holds in debug builds too.
pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool {
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; };
ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS
crate::comment_ttl::ttl_ok(
crate::comment_ttl::CommentTtlRule::Window {
min_ttl_ms: REGISTRATION_TTL_MS,
max_ttl_ms: REGISTRATION_TTL_MS,
},
timestamp_ms,
expires_at_ms,
)
}
/// One search hit from the local registry chain.
@ -322,6 +335,12 @@ mod tests {
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
assert_eq!(gating.wrap_slots.len(), 1);
// v0.8: the flat-30d retention for EVERY comment on this post is
// part of the canonical bytes, carried in the generic per-post
// policy field.
assert_eq!(post.comment_ttl, Some(REGISTRY_COMMENT_TTL_POLICY));
assert_eq!(post.comment_ttl.unwrap().min_ttl_ms, REGISTRATION_TTL_MS);
assert_eq!(post.comment_ttl.unwrap().max_ttl_ms, REGISTRATION_TTL_MS);
}
/// The registry open slot must actually open under the derivable key
@ -472,3 +491,4 @@ mod tests {
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
}
}

View file

@ -1 +1 @@
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}}
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}},"comment_ttl":{"min_ttl_ms":2592000000,"max_ttl_ms":2592000000}}

View file

@ -50,6 +50,26 @@ pub struct Storage {
conn: Connection,
}
/// The SELECT list every `Post`-reconstructing query MUST use, in this
/// order (see [`Storage::parse_post_row`]).
///
/// A `PostId` is `BLAKE3(Post)` — the post IS its fields. A query that
/// omits one hands back a struct whose id no longer verifies, and every
/// receiver (`content::verify_post_id` on the sync, pull and import
/// paths) then discards the post as a forged signature, with no
/// diagnostic. That is exactly how `fof_gating` posts — and, once
/// authors can set one, any post carrying a `comment_ttl` retention
/// policy — became unsyncable and unexportable.
const POST_COLUMNS: &str =
"id, author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json";
/// [`POST_COLUMNS`] qualified for queries that alias `posts` as `p`.
const POST_COLUMNS_P: &str =
"p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility, p.fof_gating_json, p.comment_ttl_json";
/// One raw row in [`POST_COLUMNS`] order.
type PostRow = (Vec<u8>, Vec<u8>, String, String, i64, String, Option<String>, Option<String>);
/// Pool of Storage connections for concurrent SQLite access in WAL mode.
/// Each connection is independently locked — readers don't block each other.
/// Uses tokio::sync::Mutex so guards are Send (safe across .await points).
@ -965,9 +985,36 @@ impl Storage {
)?;
}
// v0.8: post.comment_ttl — the per-post comment retention policy
// (`crate::comment_ttl`). MUST be persisted: holders enforce
// incoming comments' TTLs against the parent post they read back
// out of this table, so dropping the column on the floor would
// silently degrade every TTL'd post to the class defaults.
let has_comment_ttl = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name='comment_ttl_json'"
)?.query_row([], |row| row.get::<_, i64>(0))?;
if has_comment_ttl == 0 {
self.conn.execute_batch(
"ALTER TABLE posts ADD COLUMN comment_ttl_json TEXT;"
)?;
}
Ok(())
}
/// Serialize `post.comment_ttl` for the `comment_ttl_json` column.
fn comment_ttl_json(post: &Post) -> anyhow::Result<Option<String>> {
Ok(match &post.comment_ttl {
Some(p) => Some(serde_json::to_string(p)?),
None => None,
})
}
/// Parse a `comment_ttl_json` column value back into a policy.
fn parse_comment_ttl(raw: Option<String>) -> Option<crate::types::CommentTtlPolicy> {
raw.and_then(|s| serde_json::from_str(&s).ok())
}
// ---- Posts ----
/// Store a post with default Public visibility. Returns true if it was new.
@ -989,8 +1036,8 @@ impl Storage {
None => None,
};
let inserted = self.conn.execute(
"INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id.as_slice(),
post.author.as_slice(),
@ -999,6 +1046,7 @@ impl Storage {
post.timestamp_ms as i64,
visibility_json,
fof_json,
Self::comment_ttl_json(post)?,
],
)?;
if inserted > 0 {
@ -1009,7 +1057,7 @@ impl Storage {
pub fn get_post(&self, id: &PostId) -> anyhow::Result<Option<Post>> {
let mut stmt = self.conn.prepare(
"SELECT author, content, attachments, timestamp_ms, fof_gating_json
"SELECT author, content, attachments, timestamp_ms, fof_gating_json, comment_ttl_json
FROM posts WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id.as_slice()])?;
@ -1020,6 +1068,7 @@ impl Storage {
let fof_json: Option<String> = row.get(4)?;
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
let comment_ttl = Self::parse_comment_ttl(row.get(5)?);
Ok(Some(Post {
author: blob_to_nodeid(row.get(0)?)?,
content: row.get(1)?,
@ -1035,6 +1084,7 @@ impl Storage {
// dedicated column if/when receivers need to render the
// supersedes pointer in feeds.
supersedes_post_id: None,
comment_ttl,
}))
} else {
Ok(None)
@ -1046,7 +1096,7 @@ impl Storage {
id: &PostId,
) -> anyhow::Result<Option<(Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json
"SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json
FROM posts WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id.as_slice()])?;
@ -1059,6 +1109,7 @@ impl Storage {
let fof_json: Option<String> = row.get(5)?;
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
let comment_ttl = Self::parse_comment_ttl(row.get(6)?);
Ok(Some((
Post {
author: blob_to_nodeid(row.get(0)?)?,
@ -1067,6 +1118,7 @@ impl Storage {
timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating,
supersedes_post_id: None,
comment_ttl,
},
visibility,
)))
@ -1133,99 +1185,49 @@ impl Storage {
/// All posts, newest first (with visibility)
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
/// Feed: posts from followed users, reverse chronological (with visibility)
pub fn get_feed(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS_P}
FROM posts p
INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
/// Feed: paginated — posts from followed users, cursor-based by timestamp
pub fn get_feed_page(&self, before_ms: Option<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let sql = if before_ms.is_some() {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
format!(
"SELECT {POST_COLUMNS_P}
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE p.timestamp_ms < ?1
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC LIMIT ?2"
ORDER BY p.timestamp_ms DESC LIMIT ?2")
} else {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
format!(
"SELECT {POST_COLUMNS_P}
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC LIMIT ?2"
ORDER BY p.timestamp_ms DESC LIMIT ?2")
};
let mut stmt = self.conn.prepare(sql)?;
let mut stmt = self.conn.prepare(&sql)?;
let rows = if let Some(bms) = before_ms {
stmt.query_map(rusqlite::params![bms as i64, limit as i64], Self::parse_post_row)?
} else {
@ -1237,20 +1239,22 @@ impl Storage {
/// All posts: paginated — cursor-based by timestamp
pub fn list_posts_page(&self, before_ms: Option<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let sql = if before_ms.is_some() {
"SELECT id, author, content, attachments, timestamp_ms, visibility
format!(
"SELECT {POST_COLUMNS}
FROM posts
WHERE timestamp_ms < ?1
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC LIMIT ?2"
ORDER BY timestamp_ms DESC LIMIT ?2")
} else {
"SELECT id, author, content, attachments, timestamp_ms, visibility
format!(
"SELECT {POST_COLUMNS}
FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC LIMIT ?2"
ORDER BY timestamp_ms DESC LIMIT ?2")
};
let mut stmt = self.conn.prepare(sql)?;
let mut stmt = self.conn.prepare(&sql)?;
let rows = if let Some(bms) = before_ms {
stmt.query_map(rusqlite::params![bms as i64, limit as i64], Self::parse_post_row)?
} else {
@ -1354,30 +1358,51 @@ impl Storage {
Ok(result)
}
/// Helper: parse a post row from a query
fn parse_post_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Vec<u8>, Vec<u8>, String, String, i64, String)> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?))
/// Helper: parse a post row from a query. The SELECT list MUST be
/// [`POST_COLUMNS`] (or its `p.`-qualified twin) — see that constant
/// for why a missing column is a silent network-wide drop.
fn parse_post_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PostRow> {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
}
/// Helper: one raw row → the content-addressed triple.
fn post_from_row(row: PostRow) -> anyhow::Result<(PostId, Post, PostVisibility)> {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json, fof_json, ttl_json) = row;
let attachments: Vec<Attachment> =
serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
Ok((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating,
// Not persisted as a column today (see `get_post`).
supersedes_post_id: None,
comment_ttl: Self::parse_comment_ttl(ttl_json),
},
visibility,
))
}
/// Helper: collect parsed post rows into typed results
fn collect_posts(rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<(Vec<u8>, Vec<u8>, String, String, i64, String)>>) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
fn collect_posts(rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<PostRow>>) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
posts.push(Self::post_from_row(row?)?);
}
Ok(posts)
}
@ -1386,37 +1411,11 @@ impl Storage {
/// Includes control/profile posts — they need to propagate through the
/// CDN like any other post.
pub fn list_posts_with_visibility(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts ORDER BY timestamp_ms DESC",
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
// ---- Follows ----
@ -3119,8 +3118,8 @@ impl Storage {
};
let inserted = self.conn.execute(
"INSERT OR IGNORE INTO posts
(id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json, comment_ttl_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
id.as_slice(),
post.author.as_slice(),
@ -3130,6 +3129,7 @@ impl Storage {
visibility_json,
intent_json,
fof_json,
Self::comment_ttl_json(post)?,
],
)?;
if inserted > 0 {
@ -3190,30 +3190,14 @@ impl Storage {
// Use LIKE to find posts whose visibility_intent JSON contains the circle name.
// The serialized form is {"Circle":"name"} so we search for that pattern.
let pattern = format!("%\"Circle\":\"{}\"%", circle_name);
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts WHERE author = ?1 AND visibility_intent LIKE ?2",
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts WHERE author = ?1 AND visibility_intent LIKE ?2",
))?;
let rows = stmt.query_map(
params![our_node_id.as_slice(), pattern],
Self::parse_post_row,
)?;
let mut posts = Vec::new();
let mut rows = stmt.query(params![our_node_id.as_slice(), pattern])?;
while let Some(row) = rows.next()? {
let attachments: Vec<Attachment> =
serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or_default();
let visibility: PostVisibility =
serde_json::from_str(&row.get::<_, String>(5)?).unwrap_or_default();
posts.push((
blob_to_postid(row.get(0)?)?,
Post {
author: blob_to_nodeid(row.get(1)?)?,
content: row.get(2)?,
attachments,
timestamp_ms: row.get::<_, i64>(4)? as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
Self::collect_posts(rows)
}
// ---- Replica tracking ----
@ -6205,8 +6189,15 @@ impl Storage {
pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index),
group_sig = COALESCE(excluded.group_sig, group_sig),
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload),
expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0
THEN excluded.expires_at ELSE expires_at END",
-- v0.8: the expiry is INSIDE the signed comment digest, so
-- the incoming value is always author-committed including
-- NULL, which is how the `0` never-expires sentinel is
-- stored. Taking it unconditionally is what makes the
-- sentinel honoured by EVERY consumer: the old
-- `excluded.expires_at > 0` guard (written when NULL meant
-- \"legacy, unknown\") would keep a stale TTL on a comment
-- its author republished as permanent.
expires_at = excluded.expires_at",
params![
comment.author.as_slice(),
comment.post_id.as_slice(),
@ -6592,7 +6583,7 @@ impl Storage {
author: &NodeId,
) -> anyhow::Result<Vec<(PostId, Post)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json, comment_ttl_json
FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL
ORDER BY timestamp_ms DESC",
)?;
@ -6603,11 +6594,12 @@ impl Storage {
let attachments: String = row.get(3)?;
let ts: i64 = row.get(4)?;
let fof_json: Option<String> = row.get(5)?;
Ok((id, author, content, attachments, ts, fof_json))
let ttl_json: Option<String> = row.get(6)?;
Ok((id, author, content, attachments, ts, fof_json, ttl_json))
})?;
let mut result = Vec::new();
for row in rows {
let (id, author_bytes, content, attachments_json, ts, fof_json) = row?;
let (id, author_bytes, content, attachments_json, ts, fof_json, ttl_json) = row?;
let attachments: Vec<Attachment> =
serde_json::from_str(&attachments_json).unwrap_or_default();
let fof_gating = fof_json
@ -6621,6 +6613,7 @@ impl Storage {
timestamp_ms: ts as u64,
fof_gating,
supersedes_post_id: None,
comment_ttl: Self::parse_comment_ttl(ttl_json),
},
));
}
@ -7634,6 +7627,7 @@ mod tests {
timestamp_ms: ts,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id = blake3::hash(&serde_json::to_vec(&post).unwrap());
s.store_post(id.as_bytes(), &post).unwrap();
@ -8264,6 +8258,7 @@ mod tests {
timestamp_ms: ts,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -8452,6 +8447,85 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// v0.8: `Post.comment_ttl` MUST survive the storage round-trip.
/// Holders enforce incoming comments against the parent post they
/// read back out of this table — if the policy were dropped here,
/// every TTL'd post would silently fall back to the class defaults
/// and the registry's flat 30d would stop being enforced.
#[test]
fn comment_ttl_policy_round_trips() {
let s = temp_storage();
let policy = crate::types::CommentTtlPolicy::flat(7 * 24 * 3600 * 1000);
let post = Post {
author: make_node_id(1),
content: "ttl'd".into(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: Some(policy),
};
let id = crate::content::compute_post_id(&post);
s.store_post(&id, &post).unwrap();
assert_eq!(s.get_post(&id).unwrap().unwrap().comment_ttl, Some(policy));
assert_eq!(
s.get_post_with_visibility(&id).unwrap().unwrap().0.comment_ttl,
Some(policy),
);
// No policy stays no policy (NULL column → None, not a default).
let plain = Post { comment_ttl: None, content: "plain".into(), ..post.clone() };
let plain_id = crate::content::compute_post_id(&plain);
s.store_post(&plain_id, &plain).unwrap();
assert_eq!(s.get_post(&plain_id).unwrap().unwrap().comment_ttl, None);
// …and through EVERY list query. `PostId = BLAKE3(Post)`, so a
// query that drops the policy yields a post whose id no longer
// verifies — the ContentSync responder builds its `SyncPost`s
// from `list_posts_with_visibility` and every receiver runs
// `verify_post_id`, so a dropped field is a silent network-wide
// discard, not a cosmetic omission.
let found = |list: Vec<(PostId, Post, PostVisibility)>| -> Post {
list.into_iter().find(|(pid, _, _)| *pid == id).expect("post listed").1
};
for (label, post) in [
("list_posts_with_visibility", found(s.list_posts_with_visibility().unwrap())),
("list_posts_reverse_chron", found(s.list_posts_reverse_chron().unwrap())),
("list_posts_page", found(s.list_posts_page(None, 100).unwrap())),
] {
assert_eq!(post.comment_ttl, Some(policy), "{label} kept the retention policy");
assert!(
crate::content::verify_post_id(&id, &post),
"{label} must round-trip to a post whose id still verifies",
);
}
}
/// v0.8: `expires_at_ms == 0` is the never-expires SENTINEL, inside
/// the signed digest — so re-storing a comment as permanent must
/// clear a previously stored TTL (the upsert used to refuse it).
#[test]
fn never_expires_sentinel_clears_a_stored_ttl() {
let s = temp_storage();
let post_id = make_post_id(9);
let author = make_node_id(9);
let now = test_now_ms();
s.store_comment(&ttl_comment(author, post_id, 1000, now + 60_000)).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap()[0].expires_at_ms, now + 60_000);
// Same (author, post_id, timestamp_ms) key, republished with the
// sentinel.
s.store_comment(&ttl_comment(author, post_id, 1000, 0)).unwrap();
assert_eq!(
s.get_comments(&post_id).unwrap()[0].expires_at_ms, 0,
"the sentinel must overwrite a stored TTL",
);
assert_eq!(
s.expire_comments(now + 10 * 24 * 3600 * 1000).unwrap(), 0,
"…and the row must then survive the sweep",
);
}
/// expire_comments hard-deletes only due rows; legacy (NULL/0) rows
/// are never touched.
#[test]
@ -8537,3 +8611,4 @@ mod tests {
assert_eq!(keys[0].1, post_id);
}
}

View file

@ -55,6 +55,46 @@ pub struct Post {
/// cached. Covered by PostId since it's part of the signed Post.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_post_id: Option<PostId>,
/// v0.8: author-declared retention policy for comments on THIS post.
/// `None` = no author policy; retention then falls back to the
/// per-signing-class default taxonomy (see [`crate::comment_ttl`]).
/// Covered by `PostId = BLAKE3(Post)`, so it is immutable per publish
/// and independently verifiable — which is what lets a holder that
/// never met the author ENFORCE it against incoming comments.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment_ttl: Option<CommentTtlPolicy>,
}
/// v0.8: a per-post comment retention window. Deliberately GENERIC: the
/// registry post populates it with a flat 30 days, and a future release
/// can surface it in the composer so any author can set a retention
/// window on their own post's comments — same field, same holder-side
/// enforcement, no redesign.
///
/// `min_ttl_ms == max_ttl_ms` is a FLAT TTL (the registry case: every
/// comment must expire exactly 30 days after its own timestamp, ±clock
/// slack). `min < max` is a randomized window (the shape the open-slot
/// greeting channel uses by default).
///
/// A policy applies to EVERY comment on the post regardless of signing
/// class — that is how "all comments on a registry post expire at 30d",
/// not just registrations, falls out without an `is_registry_post`
/// special case.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommentTtlPolicy {
/// Shortest accepted TTL, in ms after the comment's own timestamp.
pub min_ttl_ms: u64,
/// Longest accepted TTL. Holders additionally clamp this to
/// `comment_ttl::MAX_COMMENT_TTL_MS`.
pub max_ttl_ms: u64,
}
impl CommentTtlPolicy {
/// A flat TTL — every comment must expire exactly `ttl_ms` after its
/// own timestamp.
pub const fn flat(ttl_ms: u64) -> Self {
Self { min_ttl_ms: ttl_ms, max_ttl_ms: ttl_ms }
}
}
/// A reference to a media blob attached to a post
@ -1228,6 +1268,16 @@ pub struct OpenSlotDecl {
pub kind: OpenSlotKind,
/// Padded plaintext bucket size in bytes (single bucket, design §27).
pub body_bucket: u16,
/// v0.8: author-declared LIMIT on how many live comments this open
/// slot will accept. `None` = the holder's own default ceiling for
/// the slot kind. Authors may only LOWER the ceiling, never raise it
/// (holders clamp) — the cap protects holders, so a post can't buy
/// itself more of someone else's storage.
///
/// Refusal is already expressible structurally: a post that declares
/// no open slot accepts no such comments at all.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_comments: Option<u32>,
}
/// FoF Layer 2: the author-published gating block embedded in a