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

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

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

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

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

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

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

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

View file

@ -1393,13 +1393,24 @@
<li>Remotely: no notice is sent. Copies held by other <code>file_holders</code> become <em>orphans</em> &mdash; nothing references them, nothing re-shares them &mdash; and are evicted naturally by the CDN's LRU eviction cycle (see <a href="#content">Content Propagation</a>).</li>
</ol>
<h3>Comment expiry: rand(30&ndash;365 days) TTL <span class="badge badge-planned">Planned</span></h3>
<p>Every comment gets an expiry timestamp at creation: <code>created_at + rand(30&ndash;365 days)</code>. The expiry is <strong>fixed at creation, signed, and carried with the comment</strong> &mdash; it is part of the comment's identity, not a per-holder policy. Every holder in the network expires the comment at (near) the same moment.</p>
<h3>Comment retention: (post policy) &times; (signing class) <span class="badge badge-complete">Implemented</span></h3>
<p>A comment's expiry is <strong>fixed at creation, signed, and carried with the comment</strong> (it is inside the comment digest, so it is part of the comment's identity, not a per-holder policy) &mdash; but <em>most comments never expire at all</em>. Which rule applies is decided by two inputs a holder can evaluate on its own: the <strong>parent post's comment-TTL policy</strong> and the <strong>comment's signing class</strong>.</p>
<table>
<tr><th>Parent post policy</th><th>Signing class</th><th>Retention</th></tr>
<tr><td>none</td><td>Public comment (no slot claim)</td><td><strong>Never expires</strong></td></tr>
<tr><td>none</td><td>Post-key-signed (the authorized/FoF commenter channel)</td><td><strong>Never expires</strong></td></tr>
<tr><td>none</td><td>Open slot &mdash; private, <em>not</em> post-key-signed (stranger greetings)</td><td><code>rand(30&ndash;365 days)</code></td></tr>
<tr><td><strong>declared</strong></td><td>any class</td><td>The post's window, for every comment on it</td></tr>
</table>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong><code>0</code> = never, and it is signed</strong>: <code>expires_at_ms == 0</code> is the never-expires sentinel. It rides inside the signed digest, so the author commits to it and holders enforce it &mdash; the expiry sweep, the ingest gate and the UI all read the same value.</li>
<li><strong>Only the stranger channel auto-expires</strong>: the randomized draw is the <em>throwaway-ID retirement mechanism</em>, so it is scoped to exactly the channel that mints throwaway IDs. Public and post-key-signed comments are ordinary content and are kept like ordinary content.</li>
<li><strong>Why random, there</strong>: a fixed TTL would let observers date a greeting's creation from its expiry. A 30&ndash;365-day uniform draw breaks that inference. (A <em>deliberately public</em> policy like the registry's does not want that anonymity and uses a flat window instead.)</li>
<li><strong>Why carried, not local</strong>: if each holder chose its own retention, staggered disappearance would fingerprint individual holders and their policies. A carried timestamp makes expiry a network-wide event that reveals nothing about who holds what.</li>
<li><strong>Why random</strong>: a fixed TTL would let observers date a comment's creation from its expiry. A 30&ndash;365-day uniform draw breaks that inference.</li>
<li><strong>Identity hygiene</strong>: throwaway persona IDs used for greeting comments (<a href="#discovery">Discovery &amp; First Contact</a>) exist in the network only through their comments. When the comments expire, the IDs vanish from uniques lists entirely &mdash; the network forgets them by construction.</li>
<li><strong>No silent extension</strong>: the TTL cannot be edited &mdash; re-posting the content is a new comment with a new ID and a fresh draw.</li>
<li><strong>The policy is generic, not a registry special case</strong>: <code>Post.comment_ttl</code> is a plain post field. The registry post (<a href="#discovery">Discovery &amp; First Contact</a>) populates it with a flat 30 days &mdash; the same mechanism a future author-set &ldquo;comments on this post expire in N days&rdquo; will use. No authoring UI ships yet; the field, the wire format and the holder-side enforcement do.</li>
<li><strong>Holders enforce, in both directions</strong>: the TTL is self-asserted, so every ingest path re-derives the rule from the parent post and drops comments that contradict it &mdash; a never-expiring comment on a TTL'd post (a permanent registry squat) <em>and</em> an over-long TTL on the stranger channel (a permanent squat on someone's greeting budget). A holder that does not hold the parent post has not seen its policy, so it accepts a bounded TTL (which self-heals) but never grants permanence. Absolute ceiling on any accepted TTL: 366 days.</li>
<li><strong>No silent extension</strong>: the TTL cannot be edited &mdash; re-posting the content is a new comment with a new signature and a fresh draw.</li>
<li><strong>Consequence</strong>: permanent comment classes mean comment chains grow monotonically, so the 10 MB blob split is the only real bound &mdash; which raises the value of range-tree reconciliation over a flat op-ID exchange.</li>
</ul>
</section>
@ -1432,7 +1443,7 @@
<li><strong>Jittered, tiered update cadence</strong>: CDN update checks run on a descending-frequency schedule (minutes &rarr; days) keyed on freshness &times; relationship tier, with jitter on every timer (see <a href="#keep-alive">Update Cadence</a>). No check fires <em>because</em> a post appeared; checks fire because a schedule slot came up.</li>
<li><strong>Replication shaped as overhead</strong>: replication pushes are paced and jittered so an author seeding their own content is indistinguishable from a holder performing routine redundancy maintenance. The goal: update + replication traffic reads as uniform network overhead, with no observable "origin burst".</li>
<li><strong>2&ndash;5 visible posts per author</strong>: the public CDN surface reveals at most 2&ndash;5 posts per author at any time; the surplus is pushed out to holders via replication requests and reached through the uniques index. This caps what enumeration of any single node's public holdings discloses about any author's corpus &mdash; and hides the author's own node behind ordinary public mesh IDs.</li>
<li><strong>Comment TTL</strong>: long-lived comment trails are the easiest correlation anchors across time; the 30&ndash;365-day expiry bounds how long any trail exists.</li>
<li><strong>Open-slot comment TTL</strong>: long-lived comment trails are the easiest correlation anchors across time. The 30&ndash;365-day expiry bounds how long a <em>throwaway-ID</em> trail exists &mdash; it applies to the open-slot stranger channel (greetings), which is where the anonymity claim lives. Public and post-key-signed comments do not expire; they are attributable content, and their trail is bounded by nothing but deletion.</li>
</ul>
<div class="note">
<strong>Honest scope</strong>: this defeats passive observers correlating timing and public holdings. A global adversary who can watch every link, or an adversary who compromises your own device, is out of scope &mdash; as in every friend-to-friend design.
@ -1694,7 +1705,7 @@
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>The post</strong>: published at network genesis by the default anchor's persona; its ID is derived from a well-known seed the app ships with (<code>shard(seed, k)</code> &mdash; shard count starts at 1, and the derivation scheme exists from day one so splitting by <code>hash(persona_id)</code> later is a threshold change, not a migration). It replicates as ordinary CDN content; anchors hold it by durability, not appointment.</li>
<li><strong>The entry</strong>: a registration comment carries {display name, search keywords, persona ID / public author ID} <strong>signed by the registering persona's posting key</strong> &mdash; self-certifying: any holder can verify the entry against the key it names, no authority consulted.</li>
<li><strong>Self-cleaning</strong>: registration comments carry a <strong>fixed 30-day TTL</strong> (unlike ordinary comments' randomized TTL &mdash; that randomness serves anonymity, which a deliberately-public registration doesn't want). Staying listed means re-signing a fresh entry; holders keep <strong>one active entry per persona ID</strong> (newest wins). The chain self-cleans and is never more than 30 days stale.</li>
<li><strong>Self-cleaning</strong>: the registry post <strong>declares a flat 30-day comment-TTL policy</strong>, and that policy binds <em>every</em> comment on the post &mdash; registrations, duplicate-reports, anything &mdash; not just the ones that parse as entries. (Flat rather than randomized: the randomness serves anonymity, which a deliberately-public registration doesn't want, and a flat window lets clients simply assume &ldquo;renew before 30 days&rdquo; with no per-entry expiry tracking.) It is the generic per-post policy of <a href="#deletes">Comment retention</a>, not a registry special case. Staying listed means re-signing a fresh entry; holders keep <strong>one active entry per persona ID</strong> (newest wins). The chain self-cleans and is never more than 30 days stale.</li>
<li><strong>Delete / modify</strong>: a delete request signed by the same persona key is honored by every holder (verifiable from the entry itself &mdash; the network agreement is checkable, not honor-system). Modify = delete + re-register; no separate primitive needed.</li>
<li><strong>Moderation hook</strong>: the registry post's author retains the standard per-post <code>RevocationEntry</code> power &mdash; manual spam removal for the alpha era, at the acknowledged cost that the author <em>could</em> censor entries. Acceptable while the author is the project's own anchor; the trust layer (see <a href="#directory">Directory Trust Layer</a>) is the eventual replacement.</li>
<li><strong>Flood limits</strong>: registrations fit a single small size bucket and are rate-capped per holder. <em>Proof-of-work was considered and rejected</em>: its cost lands inverted (a phone registrant pays seconds of battery; a botnet rig pays effectively nothing), so it taxes the users we want while barely inconveniencing the adversary we fear. The registry's real defenses are the bounded blast radius and vouch gating below.</li>
@ -1709,7 +1720,7 @@
<li><strong>Sealed content</strong>: the greeting body is HPKE-sealed to the bio author's posting key. Inside: the sender's <em>real</em> persona ID and a return path. Outside: an opaque ciphertext authored by a <strong>throwaway outer ID</strong>. Observers learn that the bio received <em>a</em> greeting, not from whom.</li>
<li><strong>Blends in</strong>: FoF Mode&nbsp;2 already makes encrypted comment blobs commonplace on public posts. Greetings are one more ciphertext among many &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Off-switch</strong>: the author revokes the greeting <code>pub_x</code> via the existing per-post <code>RevocationEntry</code> sweep &mdash; propagation nodes delete stored greetings and stop accepting new ones. No new revocation machinery.</li>
<li><strong>Retirement</strong>: comments expire at a random TTL fixed at creation (see <a href="#deletes">Delete Propagation &amp; Comment Expiry</a>), so throwaway greeting IDs eventually vanish from the network &mdash; and from everyone's uniques lists &mdash; entirely.</li>
<li><strong>Retirement</strong>: open-slot comments &mdash; the one class that auto-expires &mdash; carry a random TTL fixed at creation (see <a href="#deletes">Comment retention</a>), so throwaway greeting IDs eventually vanish from the network, and from everyone's uniques lists, entirely.</li>
</ul>
<h3>Spam controls</h3>
@ -1717,7 +1728,8 @@
<tr><th>Control</th><th>Mechanism</th></tr>
<tr><td>Active choice</td><td>No greeting slot in the bio &rarr; no greetings possible. The choice is made visibly at first profile publish &mdash; pre-checked YES with opt-out before publishing (alpha default; revisit for public release). Revocable per persona at any time.</td></tr>
<tr><td>Size bucket</td><td>Greetings fit a single small padded bucket &mdash; no attachments, no long-form spam payloads.</td></tr>
<tr><td>Holder-side count caps</td><td>Nodes holding a bio cap how many unexpired greetings they store and forward per bio.</td></tr>
<tr><td>Holder-side count caps</td><td>Nodes holding a bio cap how many unexpired greetings they store and forward per bio (default 64).</td></tr>
<tr><td>Author-declared limit</td><td>The bio's open-slot declaration carries an optional <code>max_comments</code> &mdash; &ldquo;accept at most N live stranger comments on this post&rdquo; &mdash; signed into the post and enforced by every holder. Authors may <em>lower</em> the holder default, never raise it (the storage being spent is the holder's). This is the <em>limit</em> half of the per-post control; <em>refusal</em> is the structural half above: declare no open slot and no such comment is possible. Generic to open slots, not greeting-specific.</td></tr>
<tr><td>Rate caps</td><td>Holder-side per-bio limits on unexpired greetings stored and forwarded. (Proof-of-work stamps were considered and rejected &mdash; inverted cost: phones pay, botnets don't.)</td></tr>
</table>
<div class="note">
@ -1949,8 +1961,11 @@
<!-- Ruling: project_v08_mesh_redesign.md round 4 — per-disconnect stochastic action replaces threshold+jitter -->
<tr><td>CONVECTION_ACTION</td><td>stochastic, per-disconnect</td><td><span class="badge badge-complete">Implemented</span> On each mesh disconnect: random {nothing | anchor introduction | mesh-peer introduction}; weights adaptive = local anchor-density prior + refusal feedback</td></tr>
<tr><td>CONVECTION_CLASS</td><td>entry / top-up (1 bit)</td><td><span class="badge badge-complete">Implemented</span> Entry (&lt;2 connections) always served; top-up refused cheaply under load &mdash; refusal feeds the adaptive weights</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — comment TTL rand(30365d) fixed at creation -->
<tr><td>COMMENT_TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Comment expiry, fixed at creation and carried with the comment</td></tr>
<!-- Ruling 2026-08-03: retention = (post policy) × (signing class); comment_ttl.rs -->
<tr><td>COMMENT_TTL (public)</td><td>never expires (<code>0</code> sentinel)</td><td><span class="badge badge-complete">Implemented</span> Ordinary public comments are kept like ordinary content</td></tr>
<tr><td>COMMENT_TTL (post-key-signed)</td><td>never expires (<code>0</code> sentinel)</td><td><span class="badge badge-complete">Implemented</span> The authorized/FoF commenter channel</td></tr>
<tr><td>COMMENT_TTL (open slot)</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-complete">Implemented</span> The private, non-post-key-signed stranger channel &mdash; fixed at creation, carried with the comment; retires throwaway greeting IDs</td></tr>
<tr><td>COMMENT_TTL (per-post policy)</td><td>overrides every class</td><td><span class="badge badge-complete">Implemented</span> <code>Post.comment_ttl</code>; registry post = flat 30 days. Holder ceiling on any accepted TTL: 366 days</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — descending update cadence minutes→days -->
<tr><td>UPDATE_CADENCE</td><td>minutes &rarr; days</td><td><span class="badge badge-rework">Rework</span> Descending-frequency check scale keyed on freshness &times; relationship tier, jittered (seed: ENGAGEMENT_CHECK_TIERS above; see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>)</td></tr>
</table>
@ -1980,8 +1995,9 @@
<tr><td>File holders per content</td><td>5 (LRU)</td><td>Flat <code>file_holders</code> set; registration past 5 answers with a holder redirect</td></tr>
<!-- Ruling: publicly reveal only 25 posts per author -->
<tr><td>Publicly revealed posts per author</td><td>2&ndash;5</td><td><span class="badge badge-planned">Planned</span> Concealment budget; surplus pushed out via replication (see <a href="#privacy">Social Graph Privacy</a>)</td></tr>
<!-- Ruling: comment TTL rand(30365d) fixed at creation -->
<tr><td>Comment TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Fixed at creation, carried with the comment; retires throwaway greeting IDs</td></tr>
<!-- Ruling 2026-08-03: retention = (post policy) × (signing class); comment_ttl.rs -->
<tr><td>Comment TTL</td><td>by class: never / never / rand(30&ndash;365 days)</td><td><span class="badge badge-complete">Implemented</span> Public and post-key-signed comments never expire; only the open-slot stranger channel draws a TTL. A per-post policy (registry = flat 30d) overrides every class; 366-day holder ceiling (see <a href="#deletes">Comment retention</a>)</td></tr>
<tr><td>Open-slot comments per post</td><td>author-declared, clamped to 64 (greeting)</td><td><span class="badge badge-complete">Implemented</span> <code>OpenSlotDecl.max_comments</code>; authors may lower the holder default, never raise it</td></tr>
<!-- crypto.rs:522 per-recipient wrapping; ~500/256KB never enforced (audit B7) -->
<tr><td>Max recipients (per-recipient wrapping)</td><td>~500 / 256 KB</td><td>Design target for WrappedKey lists; not enforced in code</td></tr>
<tr><td>Public post encryption overhead</td><td>Zero</td><td>No WrappedKeys, unlimited audience</td></tr>
@ -2047,7 +2063,7 @@
<tr><td>AuthorManifest privacy (no device addresses, 2&ndash;5 neighborhood)</td><td><span class="badge badge-rework">Rework</span> &mdash; code ships addresses + 10+10 neighborhood</td></tr>
<tr><td>Engagement propagation via flat <code>file_holders</code> + signature verification</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Deletes as signed control posts (+ LRU blob orphaning)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Comment TTL expiry (rand 30&ndash;365d, fixed at creation)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Comment retention: (post policy) &times; (signing class); open-slot rand 30&ndash;365d, fixed at creation</td><td><span class="badge badge-complete">Implemented</span> &mdash; writer draw, holder-side enforcement in both directions, and the expiry sweep</td></tr>
<tr><td>Author concealment (2&ndash;5 visible posts) + traffic-uniformity shaping</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Push notifications, reactions, comments, tombstones, auto-sync on follow</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Audience sharding (large-audience optimization)</td><td><span class="badge badge-planned">Planned</span></td></tr>
@ -2082,7 +2098,7 @@
</div>
<div class="card">
<h3>2. Registry + greeting comments &mdash; tester-critical</h3>
<p><strong>The app is eminently useful only if people can find each other</strong> &mdash; this is what makes it usable for testers, so it jumps the queue. Ship the &ldquo;registrations here&rdquo; post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, holder-side rate/size caps &mdash; no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) &mdash; one open-slot comment implementation serves both. <strong>Prerequisite folded in</strong>: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.</p>
<p><strong>The app is eminently useful only if people can find each other</strong> &mdash; this is what makes it usable for testers, so it jumps the queue. Ship the &ldquo;registrations here&rdquo; post (open-slot signed registration comments, a flat 30-day comment-TTL policy binding every comment on the post, one entry per persona, self-certifying signed deletes, holder-side rate/size caps &mdash; no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) &mdash; one open-slot comment implementation serves both. <strong>Prerequisite folded in</strong>: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.</p>
</div>
<div class="card">
<h3>3. Cruft purge</h3>
@ -2102,7 +2118,7 @@
</div>
<div class="card">
<h3>7. Update-cadence system</h3>
<p>Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness &times; relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. Comment TTL (rand 30&ndash;365d) lands with this work.</p>
<p>Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness &times; relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. (Comment retention shipped ahead of this work &mdash; see <a href="#deletes">Comment retention</a>.)</p>
</div>
<div class="card">
<h3>8. Raw-UDP EDM refactor</h3>