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:
parent
fa02ace4cc
commit
8b042f6598
20 changed files with 1676 additions and 255 deletions
|
|
@ -145,6 +145,7 @@ pub fn build_announcement_post(
|
|||
timestamp_ms,
|
||||
fof_gating: None,
|
||||
supersedes_post_id: None,
|
||||
comment_ttl: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
480
crates/core/src/comment_ttl.rs
Normal file
480
crates/core/src/comment_ttl.rs
Normal 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 30–365d |
|
||||
//! | 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 30–365d.
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 30–365d 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
|
||||
/// 30–365d 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(®istry, &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(®istry), &never), "no-expiry rejected on a TTL'd post");
|
||||
assert!(!gate_via_pull(
|
||||
&s, &crate::registry::REGISTRY_POST_ID, ®istry.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(®istry), &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(®istry), 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 (30–365d) 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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2417,6 +2417,7 @@ mod tests {
|
|||
timestamp_ms: 1000,
|
||||
fof_gating: None,
|
||||
supersedes_post_id: None,
|
||||
comment_ttl: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 30–365d 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(®istry_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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue