Guest comments, discovered client-side and blessed into the fold #
Implements the plan end to end — all eight steps, including the UI and the docs.
Branch radial/impl-32b840557498 · Commit 7046702 (base b16d9fb)
What landed #
Three new lexicons (packages/lexicons/lexicons/), all new collections, so the additive-only
rule holds trivially and no signed record is affected:
com.disnetdev.radial.setGuestComments{space, enabled}— admin-authored, latest-wins over a default offalsecom.disnetdev.radial.blessComment{goal, comment, body}— the guest'smessagepinned byuri#cidplus a snapshot of the body at that versioncom.disnetdev.radial.retractBless{bless}— withdrawn by the blessing's author or an admin
Core. GoalView.blessedComments / .retractedBlessings (goal matched by URI, like a message
and like an ending — a legacy closed edit already minted a second goal cid);
MaterializedIndex.guestCommentsEnabled; a blessComment invariant in validation.ts (the
comment ref must name a message and must not be the goal). TurnBundle.guestComments is built
from blessedComments and from nothing else, and is deliberately not merged into thread — so
an answer request, which resolves its subject out of threadMessages alone, can never name a
guest comment.
Digest. A new blessedComments section. A blessing is a record (records) and a decision;
without the second, a retractBless one operator had ingested and the other had not would leave
both digests identical while their bundles differed.
Sidecar. radial comment bless --goal REF --comment AT-URI, radial comment retract --bless REF, radial space guest-comments --space REF --enabled true|false. bless fetches the guest's
record through the existing resolver (which reaches any DID's PDS), refuses it if the record does not
itself name the goal, and copies the cid and body from that one read.
Daemon. A ## Community comments section in bundle.md, after the thread, naming both DIDs.
Guest bodies are deliberately excluded from the image-locator scan.
UI. New src/lib/guests.ts (Constellation client, member filtering, foreign fetch,
re-validation, the section's rows), a Community section and a guest composer on the goal page
behind guestCommentsEnabled, and the admin switch on the space page. Blessing is a two-step with
the full text in front of the reader. The three collections were added to RADIAL_WRITES.
The load-bearing decision #
Blessing copies rather than points — docs/adr-blessing-copies-a-guest-comment.md. A pointer
would put IO inside the fold (every materializer, browser tab included, fetching a repo nobody
polls), would let a guest change what a member vouched for, and would make two daemons' bundles
differ by dispatch timing. The snapshot is also what lets the section still draw something when
Constellation is unreachable.
Discovery never enters RecordStore, materialize() or the digest, so two materializers with the
same records agree whether or not that third-party service answered.
Verification #
pnpm lint, pnpm test, pnpm build all green; generate.mjs --check current.
- The 200-permutation whole-index
deepEqualnow covers the new records:goldenScenario()grows a guest who authors nothing in the fixture (the invariant, not a gap), five blessings — one live, one withdrawn by an admin who did not write it, one by its own author, one from a since-removed member, one naming a goal the space does not hold — a retract from a member with no authority, and adminoff→ontoggles plus a non-admin one. - Negative cases asserted explicitly: a bless from a non-member is dropped by trust; an unauthorized
retract associates and does nothing; an unblessed comment is absent from
buildTurnBundle()and frombundle.md; a retracted blessing disappears from both. bundle-writer.test.mjsholds the structural guarantee over the source:bundle.guestCommentsis the only thing the serializer reads guest prose from, and the image scan does not touch it.guests.test.ts(17 cases, mocked fetch): a malformed index entry does not lose its page, a valid message filed under the wrong goal is refused, a non-messagelocator is never even fetched, an unreadable hit is counted rather than swallowed.Community.svelte.test.tsmounts the section in a real DOM and asserts a hostile body (<img src=x onerror=…>) renders as its own source with no element produced — while**bold**still renders, so it is the safe path rather than an unrendered one.guest-comments.test.mjsdrivescomment blessagainst twoLocalPdsfixtures: the body is copied, the version pinned, a mismatched goal refused, the blessing written to the member's repo.
Remaining risks #
- Constellation has no SLA and incomplete backfill. A comment written while the index was down may never be discovered. Contained: discovery is best-effort decoration, the endpoint is a single configurable constant, and everything trusted works with it entirely offline. The section says so when a lookup fails rather than showing an empty list.
- A browser cannot send a courtesy
user-agent— it is a forbidden header, and asking for a custom one instead would turn every query into a CORS preflight. TheOriginheader identifies us; this is documented at the constant. - A member blessing malicious text is the genuinely new risk, and it is bounded exactly where a
hostile member message already is (design §13). The confirm shows the full text, the bundle section
is separated and labelled, and
retractBlessgives immediate revocation — including by an admin who did not make the mistake. - Adding collections narrows an already-issued OAuth token relative to what the app now writes.
The PDS refusal is already recognised and explained by
write.ts("sign out and sign in again"); noted inpackages/ui/README.md. - The UI fixture carries no guest comments, so the section is only exercised by its own tests and by a live space — the comp this fixture reproduces has nothing to draw here.
- Not done, and out of scope per the plan: per-DID hiding (deferred until spam is real) and bless-truncation rules for a body that overflows to a blob.