feat(subspaces): Reddit-style communities + up/down votes as a focused reaction kind - #649
Conversation
…d reaction kind Subspaces — user-created communities as things: subspace (slug via root uniqueKeys, branding, rules, flairs, access public/restricted/private), relational subspace-member docs (role/approval/ban), subspace-modlog audit rows, posts gaining title/subspaceId/flairId with a server-side posting gate and a root subspaceMod state (remove → redaction for non-mods, approve, pin, lock → 423 comments incl. nested, nsfw, spoiler, flair), private-subspace fences on canView + every feed/search/trending/rss read, and the /api/v1/subspaces* family (list/create, get, update, join, leave, members, moderate, modlog, feed with hot/new/top/rising/controversial over relational tallies — one new partial index, no denormalized score). updown — upvote/downvote as a SEPARATE, deliberately limited reaction kind beside the untouched native multi-emoji reactions: one of up/down per user per post or comment (root uniqueKeys), same-direction clears, other flips, POST /api/v1/things/updown, aggregated as votes on every post/comment projection (PAT scope + rate-limit key). UI: /s directory + create modal, /s/:slug (banner, join/leave, sorts, locked composer with title + flair, sidebar), /s/:slug/mod (queue, members, bans, settings, rules, flairs, log), ▲ score ▼ pill on posts + comments with optimistic updates, subspace line/title/flair/mod badges + moderator menu on cards, subspace/title/flair in the composer, drawer entry, Settings → Subspaces preferences. Docs: TESTING checklists, CHANGELOG, PR note, TODO 25, FUNDAMENTALS §3; contracts api.things/feed/comment/user → 1.2.0; live walk in remix/scripts/verify-subspaces.mjs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erify skips - /s, /s/:slug and /s/:slug/mod drop the centered container: the header spans the viewport, the post column takes what the sticky 320–340px sidebar leaves (stacked under lg), the directory flows a responsive card grid; gutters scale with the viewport (Lopu: "not containered, full width responsive like Reddit"). - Private subspaces never self-serve a join (a left/kicked member's stale row is not an invitation) — only a moderator's add flips the row active. - verify-subspaces.mjs reports PATCH checks blocked by a database still awaiting the admin storage-accounting migration as skipped, with details. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A mod flair change edits the post crystal; the storage ledger keeps a byte-exact stamp per content row, so the raw updateOne left sizeBytes stale and locked the AUTHOR out of their next PATCH (503 "requires the current storage migration"). moderatePost now writes through updateAccountedThing — the live walk (scripts/verify-subspaces.mjs) is 94/94 with no skips. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🧹 Develop S3 preview removedThe PR-specific alias and every workflow-created develop deployment were removed when this PR closed. The ordinary generated Vercel Preview remains available on the shared development runtime. |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…und 2, S1)
Notifications: six subspace-* types join NOTIFICATION_TYPES (join-request,
join-accepted, post-removed, report, role, ban) with prefs rows, bell copy +
emoji, email copy and a subspace CTA. Subspace-scoped rows carry the subspace
shareId in targetId and lead their preview with "s/<slug> · …"
(subspaceNotificationPreview / subspaceSlugFromNotificationPreview in
registry.ts) so the bell and the email deep-link to /s/<slug>. The mod-queue
pair (join-request, report) defaults email OFF. Member actions role / ban /
unban now emit subspace-role / subspace-ban to the affected user.
POST /api/v1/subspaces/transfer { id|slug, userId|username } — owner only;
the target must be an ACTIVE member (banned 403, otherwise 404); they become
owner (approved), the caller steps down to moderator and may now leave; the
subspace doc changes hands through updateAccountedThing inside one
transaction so its bytes move ledgers; modlog owner.transfer; the new owner
is notified.
POST /api/v1/subspaces/delete { id|slug, confirmSlug } — owner only, the slug
must be retyped (s/ prefix + case forgiven, else 400). Posts survive as plain
posts: crystal.subspaceId / crystal.flairId / subspaceMod / subspacePrivate
are $unset in bounded batches through updateAccountedThings; then the
subspace doc (deleteAccountedThing), then member / modlog (/ report) rows.
Former moderators are notified (bulk ≤ 200). Returns { releasedPosts,
removedMembers }.
Registered in the route file, the Nitro import map, apiDocs (group
subspaces) and the capability pins; subspaces-members, notifications-list and
notifications-settings bump to 1.1.0 (additive).
UI: the mod page Settings tab gains the owner's Danger zone — Transfer
ownership (username + Chakra confirm modal, optimistic crown flip with
revert) and Delete subspace (retype-the-slug modal arming the red button →
Lopu toast → /s, cached copies evicted). /s/<slug> evicts its cached copy on
a 404 so a deleted subspace never repaints. Bell rows for subspace types
click through to /s/<slug>.
Tests: pure helpers (confirmSlugMatches, releasedPostUpdate, preview
round-trip) + capability pins; verify-subspaces.mjs section M covers the
role/ban notifications, every transfer/delete 4xx wall and both happy paths
(144 passed, 0 failed live).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deleting a subspace no longer publishes what an author never chose to publish: posts written behind a PRIVATE subspace's wall, and posts the moderators had REMOVED, leave as author-only posts (acl narrowed to tt:user — releaseKindFor / privatizedPostUpdate in subspaceCore.ts) instead of world-readable plain posts; everything else is released as before. The release pass matches by the fence (postThingMatch + crystal.subspaceId) so rich ['post','comment'] things are released too, retries the accounted updater's storage_conflict (bounded), and the cascade refuses (409, doc intact, safe to retry) while any post still points at the subspace rather than stranding it behind a missing doc. The response gains privatePosts (subspaces-delete 1.1.0). The deleted slug is held: the doc delete and a new control-plane subspace-tombstone row (carrying the subspaceSlug uniqueKey) land in one guarded transaction; the previous owner may re-found the slug at once, anyone else only after SUBSPACE_SLUG_HOLD_DAYS (30) — POST /api/v1/ subspaces answers 409 "held" meanwhile and /s/<slug> stays 404, so bell and email deep links can't be hijacked. New kind registered in registry.ts (schema + SUBSPACE_THINGTIME), CONTROL_PLANE_STORAGE_THINGTIMES and the projection pins. transferSubspace is race-safe: requireOwner also checks the doc's ownerId, and every write inside the transaction is conditional on the ownership/membership the gate saw (matchedCount 0 → LifecycleConflict → 409), so two transfers from the same owner commit at most once (subspaces-transfer 1.0.1). The owner cap is re-read under the session. UI: the mod page keeps the Danger zone mounted through an in-flight transfer (transferPending) so the optimistic crown flip dims the controls instead of unmounting the open confirm modal; a failed transfer lands back with the username intact. Delete copy/toast explain the private/removed handling and the slug hold. NotificationsBell keys its verb off subspaceNotificationDetail (slug head stripped) so slugs like s/deleted_scenes or s/uplifted_minds never mislabel a row. Verify section M now covers: a post removed at deletion time (404 for non-authors, private for its author), the slug hold (409 stranger / 201 previous owner / 404 meanwhile), a private-subspace deletion incl. a rich post+comment thing (author-only everywhere), and two concurrent transfers (exactly one commits, one crown on the roster). 164 passed, 0 failed live; unit families, capability pins and lint green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/781a4bdba3b43a08a3b6963e17d65d07e4ca5bd590376a5b9e8b39da14172ef3/90eb519bc24d00115d1ee90b883a35af74f9ec887d968ae77fad7978de1547cb/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
Private subspaces take JOIN REQUESTS instead of answering 403: POST /join
files the request as the same subspace-member row with `pending: true`
(200, `{ joined: false, pending: true }`) and notifies the mods
(`subspace-join-request` · "wants to join 🙋"). A pending row is NOT a
membership — `isActiveMember` is now row && !left && !banned && !pending,
so the private feed, posting, `mine=1`, member counts and transfer
eligibility all exclude it. POST /leave cancels the request. Moderators
list the queue with `GET /members?pending=1` (newest first — a re-request
restarts the row's clock) and decide with member actions `accept`
(→ active member, notifies `subspace-join-accepted`, modlog
`member.accept`) and `deny` (row dropped, optional reason, modlog
`member.deny`); a mod's `add` on a pending row accepts it too, banning a
requester removes the request, promoting one lets them in as a mod.
Restricted subspaces take POSTING-APPROVAL REQUESTS: the self action
`request-approval` (an active, unapproved member; 400 unless restricted
or when they can already post, 403 for non-members / for someone else,
idempotent, no modlog) sets `approvalRequested: true` and notifies the
mods ("wants to post ✋"); `GET /members?approvalRequests=1` lists them;
`approve` grants + clears, `unapprove` / `deny` clear.
Projection: `viewer.pending`, `viewer.approvalRequested`,
`member.pending`, `member.approvalRequested`; moderators get
`pendingCount` + `approvalRequestCount` on the detail (one $group).
Registry: the two flags on subspaceMemberSchema (+ projection pin). Pure
helpers `isActiveMembershipState` / `canPostIn` / `requestKindOf` in
subspaceCore.ts, unit-tested. Contracts: subspaces, -get, -join, -leave
→ 1.1.0, subspaces-members → 1.2.0 (docs + capability pins).
UI: subspace page + directory cards read "Request to join 🔒" →
"Requested ✓ · cancel" (optimistic, count untouched; the private wall
tells a pending requester their request is waiting); the restricted ✋
hint gains "Request posting approval ✋" → "Approval requested ✓"; "Mod
tools 🎩" carries an open-request badge linking to the mod page's new
Requests tab (join + approval queues, Accept/Approve ✓ / Deny per row,
optimistic removal + badge counts; the selected tab now pins legible
ink/card colours — the theme painted it ink-on-ink). Members rows show
"✋ asked to post".
Verify: section N (request/no-op/cancel, not-a-membership walls, mod
notifications, mod-only queues + counts, accept/deny walls + success,
re-request after deny, `add` accepting, ban removing a request, unban →
request → accept, request-approval walls + success, deny/approve/
unapprove clearing, manifest versions); section J's private join now
expects the request, K/M pins bumped. 206 passed, 0 failed. Browser pass
(Playwright, desktop + 375px) over /s/<slug>, /s, /s/<slug>/mod?tab=
requests: 17/17. Docs: TESTING.md bullet, PR note, TODO 25.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/781a4bdba3b43a08a3b6963e17d65d07e4ca5bd590376a5b9e8b39da14172ef3/90eb519bc24d00115d1ee90b883a35af74f9ec887d968ae77fad7978de1547cb/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
Join requests + posting-approval requests — the confirmed review findings.
- gate.ts: assertSubspacePosting consumes subspaceCore.canPostIn, the same
predicate the detail advertises as viewer.canPost, so a kicked (left) or
pending row can never post whatever its approved flag says (per-mode 403
copy kept). things.ts: PublicPost.subspace.viewerRole uses isActiveMember
(a pending requester is never projected as "member").
- subspaces.ts: `remove` clears approved (a kick revokes restricted posting
rights); a private re-request resets approved / approvalRequested / role;
a pending row takes only accept / deny / add / ban / role moderator —
approve, unapprove and role member answer 400, remove 404 (no half-in rows,
no stray "no longer a moderator" bell); accept / deny / add-on-pending are
guarded writes (PENDING_REQUEST_MATCH / APPROVAL_REQUEST_MATCH in the
filter) that answer 409 "withdrawn — reload the queue" when the requester
cancelled or re-filed between the read and the write, before any mod-log
entry or welcome bell; updateSubspace resolves the queues on an access
change (leaving private activates every pending row + notifies them
subspace-join-accepted "opened up", first 200; leaving restricted clears
approvalRequested; settings.update detail reports acceptedRequests /
clearedApprovalRequests); request-approval heals an expired temporary
ban on the row so the request reaches the queue and count.
- notifications.ts: emitNotificationsBulk({ dedupeUnread }) skips recipients
who already hold an unread copy (type + actor + target + preview; one
query on the partial unread index) — the mods' subspace-join-request bell
rings once per open request across join → leave → join. /join moved to
its own rate key subspaces.join (20/min).
- UI: the Requests tab refreshes on 409 instead of restoring the row; the
subspace page treats viewer.pending as a request only on private
subspaces.
- Contracts: subspaces-join 1.1.1, subspaces-members 1.2.1, subspaces-update
1.1.0 (docs, capability pins). verify-subspaces.mjs section N gained 12
review-fix checks (218 passed, 0 failed). TESTING.md / PR note / TODO.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/187cfc065de97789f3c900b420747edd5051bc6274d541014a945ad64116b458/3e6f8095762b26eb0663fcbb8a525fa70c855296939412860b5c411ebbcf8411/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
Settings (POST /api/v1/subspaces/update, any moderator): `userFlairs`
templates (the post-flair sanitizer, ≤50, modOnly = handed out by mods
only), `userFlairSelfAssign` (default true) and `allowCustomUserFlair`
(default false, custom text ≤40 chars) — both switches gate members'
self-service only. Member action `userFlair` on POST /members: self (an
ACTIVE member picks a non-modOnly template while self-assign is on, types
custom text while allowed, may always clear) or, as a moderator, anyone
but the owner (any template incl. modOnly / custom text, bound by neither
switch); only a mod dressing someone else writes a `member.userFlair`
mod-log entry. The pick lives on the subspace-member row as
`crystal.userFlair { id | null, text, emoji, color }` and resolves against
the live templates on read (rename follows every wearer, delete keeps the
snapshot); hidden unless the wearer is an active member.
Projection: `authorFlair` on PublicPost + PublicComment (the ROOT post's
subspace) from ONE `thingUniqueKeysFilter('subspaceMemberKey', keys)` per
page over every (subspace, author) pair — page docs, shared originals,
every shipped comment level — never N+1; comment docs projected as roots
(GET ?id=<comment>, thread drill-downs) resolve their root post in bounded
batched hops; the fresh comment POST /things/comment answers with carries
it too. `viewer.userFlair`, `member.userFlair` and the three settings ride
every subspace projection; the subspace embed carries `userFlairs`.
Registry: subspace fields userFlairs/userFlairSelfAssign/
allowCustomUserFlair, member field userFlair (closed object),
MAX_SUBSPACE_USER_FLAIR_TEXT_CHARS; projection pins updated. Capabilities:
subspaces / subspaces-get / subspaces-update 1.2.0, subspaces-members
1.3.0, subspaces-feed 1.1.0, things / things-comment / things-feed /
things-user feature 1.3.0 · contract 1.2.0 (docs + both pin suites).
UI: `AuthorFlairChip` after the author name on post cards, comment rows,
the shared sub-card and the mod page's member rows; `/s/<slug>` sidebar
**Your flair** card (template pills, custom text, take it off — paints
first across the viewer's own posts/comments on the page, reverts on
failure); mod page Flairs → **User flairs** editor + the two switches,
Members → **Set flair** Chakra modal. Client types + `api.v1.subspaces.
setUserFlair`. Docs: TESTING.md bullet, PR note, TODO 25.
Verify: section O (defaults, every 4xx wall, template / custom / clear,
authorFlair on posts, fresh + nested comments, comment-as-root reads, home
/ subspace / profile feeds, live template rename, mod dressing + the
mod-log rule, self-assign off, kicked / banned wearers hidden + rejoin,
manifest) — `node scripts/verify-subspaces.mjs` → 274 passed, 0 failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Projection: the post page's subspace embeds are keyed by the ROOT subspace (rootSubspaceOf) instead of each doc's own pointer, so a comment projected as the root (GET /api/v1/things?id=<comment>, thread drill-downs) resolves its authorFlair — and its replies' — against the live templates: a renamed template now follows the wearer there too, not only on the post page. addComment no longer walks the reply chain twice: assertSubspaceInteraction answers the root subspace it resolved, createThing hands it back as `rootSubspaceId`, and only a comment reconciled after an unknown transaction outcome (no gate result) walks again. Member actions: `remove` and `ban` $set crystal.userFlair null (a kick or ban strips the badge as it revokes approval — a rejoin / unban wears nothing until it is picked or granted again), `role: member` strips a MOD-ONLY pick (pure userFlairSurvivesDemotion + test; ordinary templates, custom text and orphaned snapshots stay); each stripping entry's mod-log detail reads `userFlairCleared: true`. The owner wall on `userFlair` is gone — the round-2 spec says moderators dress anyone, and the owner can always override their own pick — with the mod page showing Set flair on the owner's row. Contracts (docs + both pin suites): subspaces-join / -leave 1.2.0 (the subspace block + viewer.userFlair), -transfer 1.1.0 (newOwner.userFlair), -moderate 1.1.0 (post.authorFlair), -members 1.3.1 (the corrections above). The shared post projection stays versioned on things / things-comment / things-feed / things-user only (round-1 precedent, now written down). Mod page Set-flair modal: the "Custom text…" sentinel sits outside the flair-id grammar (`~custom`, so a template labelled "Custom" can't collide; the data-flair-id test hooks follow), a worn template the mods deleted since opens as editable custom text with a "removed" hint instead of posting a dead id, MembersPanel.mutate's busy guard now REJECTS (with a toast) instead of resolving as if it had run, applyFlair paints nothing while busy and rethrows on refusal, and the modal keeps its form open (Save disabled while another action saves) until the API says yes. Verify: section O grew the comment-as-root rename check, the owner-dressable + mod-log count, kick → rejoin wears nothing → re-pick, ban → unban wears nothing, demotion keeps an ordinary pick / strips a mod-only one (+ the userFlairCleared mod-log rows), the bumped manifest; the api() helper paces a per-user write-budget 429 (Retry-After, bounded, logged) instead of failing on a product limit the walk never asserts — `node scripts/verify-subspaces.mjs` → 283 passed, 0 failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Removal reasons: subspace crystal.removalReasons { id, title (≤80),
message (≤500) }[] (≤20, ids minted from titles, deduped) on /update for
any moderator, public on every subspace projection like the rules they
extend. `moderate remove` takes `reason` (free text) and/or `reasonId`
— a canned reason composes the stored reason "title — message · note"
(resolveRemovalReason, bounded by MAX_SUBSPACE_POST_REMOVAL_REASON_CHARS;
unknown id → 400, the post stays up), lands on subspaceMod.reason (+ root
reasonId, cleared by approve) and in the post.remove mod-log entry
(detail.reasonId). The author is notified (subspace-post-removed, preview
"s/<slug> · <reason>", postId deep-links to /post/<id>; a mod removing
their own post rings nobody; approve notifies nothing). Ban takes an
optional private `note` that lands in the member.ban mod-log detail only.
UI: components/Subspaces/ModerationModals.tsx — RemoveModal (removal
reasons, then rules, then Custom; note; also-lock; also-ban + days) in
the PostCard ··· menu, sequencing moderate(remove) [+ lock] [+ ban],
optimistic paint reverted when the remove is refused, one cached subspace
load shared with the card menu's flair list; BanModal (reason / days /
private note) replaces every window.prompt on the mod page (member rows +
Banned → Ban someone; optimistic row removal); Rules tab gains a Removal
reasons card. No window.prompt/confirm remains in the subspace UI.
Contracts: subspaces / get / update / join / leave 1.3.0, feed /
transfer 1.2.0, moderate 1.2.0, members 1.4.0 (docs, both capability pin
files). Registry schema field + example, subspaceCore unit tests, verify
section P (every wall + the happy paths, bell rows, mod log, manifest),
TESTING.md / PR note / TODO updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/187cfc065de97789f3c900b420747edd5051bc6274d541014a945ad64116b458/3e6f8095762b26eb0663fcbb8a525fa70c855296939412860b5c411ebbcf8411/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
Removal reasons + moderation modals — the confirmed review findings. - subspaces.ts: `moderate remove` is idempotent — a post that is already removed answers 200 as it is (no rewrite of removedById / removedAt / reason, no second post.remove mod-log row, no second bell for the author; input is still validated first, so an unknown reasonId stays a 400). The author's subspace-post-removed row and the subspace-ban rows (ban + unban) now come from the SUBSPACE'S MOD TEAM (subspaceModTeamActor: actorId = the subspace shareId, actorName "s/<slug> mods", actorUsername null) instead of the individual moderator — the projection deliberately hides removedById from the author and the bell no longer hands them the name; role changes and accepted requests still name the acting mod; the own-post skip is explicit. The removal preview carries the reason's HEADLINE (canned title / rule citation / free text) — previews clamp at 140 chars and the full reason is on the post the row deep-links to. - subspaceCore.ts: resolveRemovalReason takes `ruleIndex` (0-based) and composes "Rule N: title — text · note" server-side, bounded at 900 like a canned reason (out of range / no rules / both with reasonId → 400); returns the headline; rulesOf / ruleCitation helpers. slugifyFlairId falls back to a stable hashed `<prefix>-<base36 fnv1a>` id when a label has no Latin letters or digits, so CJK / Cyrillic / Arabic / emoji titles save from the id-less removal-reason and flair editors. - ModerationModals.tsx (+ moderationModalsCore.ts, pure + unit-tested): the lazy default pick only lands while the form is untouched (touchedRef); a rule pick travels as ruleIndex; the note's maxLength follows the pick (noteMaxFor — what the 900-char composed cap leaves, n/max counter, trimmed on a pick change) so the optimistic paint IS the stored text; "Also ban" sends the SHORT banReason (title / citation / custom text), never the composed removal text. PostCard wires ruleIndex + banReason. SubspaceModPage: the Removal reasons row keeps the title input wide at 375px — the id sits on its own truncating line. - Contracts: moderate 1.3.0 (ruleIndex, additive), members 1.4.1 (mod-team ban bell, correction), notifications-list 1.2.0 (mod-team actor rows, additive) — docs + both capability pin files. - Tests: subspaceCore unit tests (hashed ids, rulesOf, ruleIndex + walls + headline), moderationModalsCore.test.ts (test:subspaces now covers components/Subspaces), verify section P extended (idempotent remove, ruleIndex happy path + walls, mod-team actors on removal / ban / unban rows, headline previews, CJK reason ids, manifest pins) — 339 passed, 0 failed. TESTING.md / PR note / TODO updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Kind `subspace-report` (targetId = subspace, ownerId = reporter, one row
per (post, reporter) on the root uniqueKeys namespace `subspaceReportKey:`,
control-plane storage, part of SUBSPACE_THINGTIME, registry schema +
projection pin; no generic sanitizer so /api/v1/things refuses it).
Server
- POST /api/v1/subspaces/report { id (post or comment), reason ≤120, note
≤500 }: any logged-in viewer who can SEE the target (canViewInherited —
unknown and invisible both 404) and is not banned in its subspace; a
comment resolves to its ROOT post (commentId remembers the comment); a
repeat refreshes the row / re-opens a settled one (updated: true); only
a new or re-opened report rings the mods (subspace-report, deduped,
postId = the post). Rate key subspaces.report 30/min.
- GET /api/v1/subspaces/reports?slug&status=open|resolved&cursor&limit
(mods): rows grouped by post in one aggregate over a bounded newest-first
window → { post (mod projection), reportCount, reasons tally, reporters
≤20, latestAt, status, resolution } + openReportCount.
- POST /api/v1/subspaces/reports { postId, action: 'dismiss' } settles
every open report (dismissed; modlog report.dismiss). moderate remove /
approve settle open reports implicitly (removed / approved; modlog
detail.resolvedReports); the idempotent remove path settles too.
- Projection: subspaceMod.reportCount for the post's moderators only (one
$group per page over the pairs the viewer can moderate), openReportCount
on the detail for mods. Deleting a post deletes its reports.
- Route files + Nitro map + apiDocs (subspaces-report / subspaces-reports
1.0.0; get 1.4.0, moderate 1.4.0, feed 1.3.0, things / things-comment /
things-feed / things-user feature 1.4.0 · contract 1.3.0) + capability
test pins.
Client
- PostCard ··· menu "Report to moderators 🚩" for logged-in non-author
non-mods on subspace posts; comment rows get a flag icon (root subspace
via SubspaceReportContext); ReportModal (rules + Other + note) closes
and toasts "Reported — thanks, the mods will look 🚩" optimistically;
mods see a 🚩 N badge in the subspace line linking to the Reports tab.
- Mod page Reports tab: each reported post as its card + reasons chips +
reporters, Remove 🧹 (RemoveModal, [+ lock] [+ ban]) / Dismiss ✓ with
optimistic put-back; Open · N / Resolved toggle; badge on the tab; Mod
tools 🎩 badge = requests + reports.
Tests/docs: subspaceCore unit tests, verify section Q (every wall + happy
paths, 383 passed), TESTING.md bullet, PR note, TODO, FUNDAMENTALS §3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/187cfc065de97789f3c900b420747edd5051bc6274d541014a945ad64116b458/3e6f8095762b26eb0663fcbb8a525fa70c855296939412860b5c411ebbcf8411/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
…own votes as a focused reaction kind # Conflicts: # graphify-out/snapshots/v1/187cfc065de97789f3c900b420747edd5051bc6274d541014a945ad64116b458/3e6f8095762b26eb0663fcbb8a525fa70c855296939412860b5c411ebbcf8411/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/GRAPH_REPORT.md # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/cost.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/graph.json # graphify-out/snapshots/v1/69b6ffc74ec0cf8bd917ed94b77ba612390a3afc5c23c211b96d37077f719bc8/8aa4f228f74bb19a0ffce51ab221da3b4bbeaa3a4f408d50964601eef8b5516b/manifest.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/GRAPH_REPORT.md # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/cost.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/graph.json # graphify-out/snapshots/v1/d0450ca4937f02bf203edbc1d75fbb14c374cd09290ea53643ba06da96ffdf39/b5236856658d6e518d2dc1700eae3f0ec90d41afcee80c5f2c94a5eef628618d/manifest.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/cost.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/graph.json # graphify-out/snapshots/v1/f1ef13f07ba3ec0bc188306adb1886f1d938ea92588fc5813953e690de692f3b/0f72cdc6956bbf9bba34c83645a2aac8ce802b7846c4b1bef5aa7bae5b82c5c4/manifest.json
Comment sort on the card, four confirmed review findings: - A fresh comment under Top / Old landed outside the visible window and never painted (score 0 + newest sorts last / below every non-negative comment). windowCommentPage (feedTypes.ts, unit-tested) now pins the viewer's own fresh comments after the window under a sort, right above the composer; ids tracked by useFreshCommentIds (pending → saved on ack, dropped on failure). The reveal control keys off what is actually shown. - Picking a sort replaced post.comments wholesale, dropping an in-flight optimistic comment so its ack found nothing to swap. mergeCommentPage (unit-tested) lands the server page over the held list: the page wins the order, pending / viewer-sent rows it does not carry are kept, and commentCount = the page's + the rows it could not count yet. - A refused sort read kept the new label over the old page; the pick now reverts to the previous sort alongside the toast. - Thread drill-downs ignored the card's sort: threadCache is keyed by (id, sort), fetchThreadInto / prefetchNextDepth / get / setCachedThread take the sort, CommentRow reads it through CommentSortContext, orders and windows its replies the same way (fresh replies pinned, "Show more replies" under a sort) and refetches an open thread when the pick changes — so the client now issues the GET ?id=<comment>&commentSort= read the docs described. verify-subspaces.mjs section S pins the contracts these lean on (a comment posted under a sort sorting last / below every non-negative comment, the ack's direct commentCount beside the read's total, the drill-down read on new / old with the default read unchanged, the 400 body); 472 passed, 0 failed. TESTING.md / PR note / TODO reworded to the real behaviour (no more "keeps its place"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Final live verification of the Subspaces family after round 2: the stack restarted, remix/scripts/verify-subspaces.mjs re-read section by section against the spec's slices and definition of done, and a new section T added for the cross-slice invariants no single slice owned. Final walk: 486 passed, 0 failed. Section T walks: "the mods" a report / join request rings are the ACTIVE owner + moderators at emit time (a report after the roster moved reaches the owner only — the demoted mod and the moderator who left get no subspace-report row; a join request after a demotion rings the owner only); an ACTIVE member of a private subspace calling join is a no-op (joined false, pending false, no request filed); the Reports queue pages by cursor (limit=1 → one group + nextCursor → the other group + no cursor, openReportCount on every page); deleting a subspace takes every subspace-report row with it (the reporters' own generic reads go 200 → 404 for all three rows) while the never-removed reported post survives as a plain post and the member rows go; every bell of the family deep-links consistently across all six types (post-shaped rows postId = targetId; the subspace-shaped rows carry the subspace + an "s/<slug> ·" preview, no postId); the docs registry ↔ BOTH served manifests across the whole family at the round's final numbers (/api/v1/capabilities = the docs' contractVersion + a route.v1.* key each; /.well-known/thingtime- capabilities.json = the docs' featureVersion); the report / join docs naming their rate keys. Fixes found by the sweep: - notifications.ts: withoutUnreadDuplicates joined recipient + type with a raw NUL byte in both template strings, which made git (and the PR diff) treat the whole file as binary and grep skip it. The separator is now a space (ids are hex / uuid, types are slugs — no collision, no behaviour change); the file diffs as text against develop again. - rateLimit/config.test.ts: pins subspaces.write 60 / subspaces.join 20 / subspaces.report 30 per minute (the two fan-out emits under the shared write budget), which the spec named but no test asserted. Docs: TESTING.md gains the section-T bullet; the PR note's Verification list names section T, the fixes and the tsc result (the only errors in branch-touched files — registry.ts Feature-Stack fields without a description and the duplicate headers key in apiDocs.ts — are byte- identical on develop). Unit families green: test:subspaces 52, test:rate-limit 3, test:schemas 118, test:api-capabilities 13, thingtimeCapabilities 2, test:feed 39, test:nav 2, test:storage 10, test:things 24. Lint: 0 errors on the touched files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🤖 Lopu detected an out-of-date PR branchStatus: Work detected — Lopu is taking ownership. Current phase: Entering Lopu's serialized PR-resolution queue. Estimated completion: around 12:58 UTC (~20 minutes; this adjusts as the queue moves). Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
|
🤖 Lopu detected an out-of-date PR branchStatus: Work detected — Lopu is taking ownership. Current phase: Entering Lopu's serialized PR-resolution queue. Estimated completion: around 13:15 UTC (~20 minutes; this adjusts as the queue moves). Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
|
Live desktop (1440/1549px) + 375px pass over /s, /s/<slug>, every /s/<slug>/mod tab, the Remove / Ban / Report / Flair / Transfer / Delete modals, the feed scope chip, the Explore strip, the Search section and the Settings notification rows, as owner, member, non-mod member, pending requester and guest. Four 375px defects fixed: - PostCard action row: the vote pill made the row wider than a phone card, so the 👁 views counter was clipped by the card edge on every post (measured 428–458px in a 309–317px row). The row now wraps and the counter drops to its own right-aligned line. - Modal titles ran under the close ✕ at 375px (Ban modal title right edge 316px vs close button left 309px). ModalHeader paddingRight clears it in ModerationModals and the mod page's Flair / Transfer / Delete modals. - "Who can post" select (Create modal + mod Settings): the long access hint wrapped inside the native select and its second line was clipped. Options are now `emoji Label`; the hint renders as helper text under the select. - Mod page header: the subspace name and "← Back to s/<slug>" each broke onto two lines at 375px; the header wraps so the back pill drops to its own right-aligned line. TESTING.md gains the 375px checklist bullet. verify-subspaces.mjs: 486 passed, 0 failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the branch up to date with 301 develop commits. Conflicts resolved: notification types (subspace-* family + develop's action-run system notes, categories map, EMAIL_DEFAULT_OFF union), the bell/settings copy moved into develop's shared notificationCore (subspace META, verbs, /s/<slug> href), email CTA (system href + subspace slug), notifications-list contract merged to 1.3.0 and notifications-settings to 1.2.0 with every pin updated, package scripts, routes, FUNDAMENTALS row, CHANGELOG (round-2 entry added). graphify-out taken from one side and regenerated. Verified after the merge: unit families green, verify-subspaces.mjs 486/486, no new tsc errors in touched files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…improvements) into round 2
Integrates the seven commits the repo automation pushed to the PR branch
while round 2 was being built: two "Merge develop into claude/subspace-
communities (AI-resolved conflicts)" merges plus graphify refreshes, and
three "apply repository review improvements" commits against the round-1
subspace code.
Round-2 versions of the conflicting files are kept (they already superseded
the round-1 shapes), and every review improvement is re-applied on top:
- subspaceModHoldsPost (subspaceCore) + re-export through gate.ts; updateThing
refuses to move a post out of its subspace while a removal or comment lock
is live (403), so an author can't launder mod state by PATCHing out and back
- resolveRootPost now answers { root, truncated } behind a 512-hop rail; the
comment/vote gate and the report path fail closed (409) on an unresolved
chain instead of reading it as "not in a subspace"; the report-cascade and
fresh-comment flair callers unwrap .root
- membershipFor(subspaceId, userId, roles?) — authoritative single-row lookup
on a roles-snapshot miss; used by assertSubspacePosting,
assertSubspaceInteraction, reportPost and subspaceFeed
- canPost banned-first and kick-clears-approved were already covered by round
2's canPostIn / mutateMember; updownVotesOf via updownTalliesFor auto-merged
Graphify: the rename/rename snapshot conflict is resolved by keeping the
round-2 snapshot and re-running `scripts/graphify update .` post-merge.
Verified: test:subspaces/things/feed/api-capabilities/schemas/rate-limit/
notifications/pat-scopes/storage/moderation green; verify-subspaces.mjs
486/486 against the restarted worktree stack; eslint clean on touched files.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
🦄 Lopu review — I read this one properly rather than skimming 16.5k lines: the gate, the vote write path, the Checks & CodeQL. Conventions — verified, not assumed.
Security — the gate holds. The posting gate runs on create and on any PATCH touching The part I liked most is the truncation handling. I also checked the one deliberate disclosure rather than taking the comment's word for it: a private subspace's name, slug and moderator roster are visible to non-members, but its posts, feed, member list and activity counts are not. Validation. Dependencies aren't installed in the review worktree, so I ran the pure suites with a scratch
Two notes, neither a defect, neither changed:
Nice work — the |
|
🛰️ Lopu review — head No changes made — none were justified. Checks are clean (24 pass, 62 skipping, 0 fail; the skipping block is the control-plane's conditional matrix, not suppressed failures). CodeQL snapshot on this head is empty, so there was nothing to fix or disposition. Validation I ran in the worktree — 13 focused suites, 451 tests, 451 pass, 0 fail: What I went afterI attacked the access-control surface first, since that's where a communities feature fails dangerously.
Also verified: the fence on every read surface individually (feed, Follow-ups for the record — none blocking
On the shape of itThe relational modelling follows The comment density is high, but it carries real invariant reasoning rather than restating code. On a 16k-line access-control-heavy feature that's an asset — it's the main reason this review could reach conclusions instead of guesses. Scope note on my confidence: high on the API/data/access-control layer, which I read closely; lower on the ~5k lines of React ( |
|
🛰️ Lopu review — full head Verdict: approve. No blocking defects found, no changes made. What I checkedThe fencing core ( What holds up
Two tradeoffs worth having on the record (not defects)
ValidationDeps aren't installed in the review worktree, so I ran the changed unit tests Two further changed test files need packages absent from this environment CodeQL: the trusted snapshot for this head is empty. No alerts to fix or I verified all 14 endpoints are registered in all three required places (route — 🌈 Lopu |
🤖 Lopu detected merge conflictsStatus: Work detected — Lopu is taking ownership. Current phase: Entering Lopu's serialized PR-resolution queue. Estimated completion: around 06:00 UTC (~20 minutes; this adjusts as the queue moves). Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
|
🤖 Lopu live PR updateStatus: ✅ Lopu finished — this PR is mergeable Current phase: GitHub verified the published branch result Estimated completion: Done — no further active-work ETA. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
Technical run details — optional; this comment is the human-facing source of truth. |
🤖 Lopu detected merge conflictsStatus: Work detected — Lopu is taking ownership. Current phase: Entering Lopu's serialized PR-resolution queue. Estimated completion: around 06:11 UTC (~20 minutes; this adjusts as the queue moves). Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
|
… custom audiences + profile email) Second develop catch-up (27 commits): hidden-link keys, custom audience groups, and the profile email-visibility setting landed while round 2 was being finished. Every conflict was "both sides added something here" and is resolved by keeping both: - things.ts Viewer carries subspaceRoles AND groupIds/linkKeys; withFriendIds now loads all three in one Promise.all and memoises on all three - the post projection keeps develop's owner-only linkKey field and this branch's moderator-removal redaction of the body text - PostCard renders the subspace Remove/Report modals and develop's custom audience modal; PostComposer keeps both the subspace (title/destination/ flair) state and payload block and the custom-acl one - useApi things.get sends id, commentSort and key on the one query - the capability pins keep this branch's numbers (things 1.5.0, things-feed 1.5.0, things-comment / things-user 1.4.0) and add develop's api.users-profile 1.1.0 Graphify: the rename/rename snapshot conflict keeps this branch's snapshot and the graph is refreshed with `scripts/graphify update .`. Verified after the merge: test:subspaces 53, things 32, feed 46, api-capabilities 19, schemas 181, notifications 31, rate-limit 29, storage 17, moderation 53, acl 6 — all green; verify-subspaces.mjs 486/486 against the restarted worktree stack; tsc reports no errors in the merged files; eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🤖 Lopu live PR updateStatus: ↪️ Resolver finished; a newer conflict remains Current phase: The next detector event will own the current branch state Estimated completion: Done — no further active-work ETA. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
Technical run details — optional; this comment is the human-facing source of truth. |
🔭 Lopu review — the red
|
| analysis | uploaded | job |
|---|---|---|
/language:actions |
05:54:39Z |
✅ 60s |
/language:javascript-typescript |
06:01:06Z |
✅ 452s |
Advanced Security opens that aggregate check when the first analysis lands, and closes it if the rest don't follow in time. The fast language opened the window at 05:54:38Z; the check closed timed_out at 06:01:05Z — one second before the javascript-typescript analysis landed at 06:01:06Z.
Why this is structural, not bad luck
Upload skew (actions → javascript-typescript) vs. outcome across the open PRs:
| skew | PRs | aggregate check |
|---|---|---|
| 14–199s | #671, #677, #675, #662, #681, #682, #680, #665 | ✅ all success |
| 388s | #660 | ✅ success |
| 387s | #649 | ❌ timed_out |
| 392s | #683 | ❌ timed_out |
Every skew ≤199s passes. Both failures sit at 387–392s, and #660 surviving 388s shows the cliff is a coin flip rather than a clean threshold. It's the same shape already recorded in codeql-analysis.yml's own comments as the PR #557 incident — but reached through matrix language skew instead of duplicate scans, which is why the existing guard there can't see it. javascript-typescript analysis time grows with the tree, so this drifts further into the failing band with every large PR. A plain re-run just re-rolls the same coin.
What I did about it
Per the controller rules I did not put this fix on claude/subspace-communities. I edited only .github/workflows/codeql-analysis.yml in the protected controller checkout; the trusted step will open a dedicated Lopu fix branch against github-actions. The fix holds the fast language until the slow one has uploaded, so the check opens on the slow analysis and the skew collapses to seconds. Job names, check contexts, matrix, permissions and the concurrency group are unchanged, and run wall-clock is unchanged (it was already bound by the slow language). Validated by YAML/step-order assertions, bash -n, running the real jq selector against the failing run's live jobs payload, and simulating all five loop exit paths.
📌 #683 is hit by the same bug (392s skew) and should also clear once this lands.
CodeQL alerts: nothing to fix, nothing to dismiss
0 open alerts on this head. The analysis reports 12 results, but all 12 are pre-existing, already-dismissed alerts (js/insecure-randomness ×10, js/stack-trace-exposure, js/xss-through-dom) and none is in a file this PR touches. I dismissed nothing — greening a check that way is exactly what the disposition rules forbid.
Scope, stated honestly
I was asked to wrap up under the cooperative fleet handover once the check failure was resolved. So this pass covers check state, CodeQL posture and merge readiness end to end — it is not a line-by-line audit of all ~16.5k added lines. The security-relevant surface here (moderation authority, ban/lock/removal gates, private-subspace fences across feed/search/trending/RSS, one-vote-per-user integrity) deserves a dedicated review pass rather than being taken on the description's word. Nothing I inspected contradicted the evidence in the description, and every check that exercises this code — build + typecheck ratchet + unit tests, the headless API suite, both CodeQL analyses, contract advisories, GitGuardian — is green.
This PR is not blocked by anything in its own diff. 🪐
— 🤖 Lopu, automated repository steward
🤖 Lopu detected merge conflictsStatus: Work detected — Lopu is taking ownership. Current phase: Entering Lopu's serialized PR-resolution queue. Estimated completion: around 07:40 UTC (~20 minutes; this adjusts as the queue moves). Next automatic check-in: within 10 minutes while work remains active. You can stay on this PR; there is no need to find the Actions run. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
|
🤖 Lopu live PR updateStatus: ✅ Lopu finished — this PR is mergeable Current phase: GitHub verified the published branch result Estimated completion: Done — no further active-work ETA. Time conversion (UTC source)
Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically. Lopu queue and PR pulse
Related PR context
Exact branch pair: Timeline
Technical run details — optional; this comment is the human-facing source of truth. |
|
🤝 Merged Conflicted files:
Please review the merge commit before relying on it. |
Conflicted paths: FUNDAMENTALS.md, graphify-out/snapshots/v1/ff44c56dbc4ae2c598506d2f1fc765839e5f1f6cc68c2757fa5851ef71830c07/141d176fa382df8ef1677a27682eae677ac66220fe46b6fc0b9815d9b9f3315d/cost.json, graphify-out/snapshots/v1/ff44c56dbc4ae2c598506d2f1fc765839e5f1f6cc68c2757fa5851ef71830c07/141d176fa382df8ef1677a27682eae677ac66220fe46b6fc0b9815d9b9f3315d/graph.json, remix/CHANGELOG.md Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/34102168243 Lopu-Conflict-Resolution: run=34102168243 pr=649 Co-Authored-By: Lopu <github-actions[bot]@users.noreply.github.com>
Structural `graphify update` completed (graphify 0.9.4); LLM semantic extraction failed, so the verified structural graph was preserved. Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/34102168243 Lopu-Conflict-Resolution: run=34102168243 pr=649
|
🤖 Promotion conflict resolution was queued automatically for Conflicted source paths: The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed. |
|
Branch: No newer branch work was overwritten or deleted. The same immutable snapshot will not spend AI again; a changed source/base snapshot retries automatically. Review the run. |
|
Promoted to Merging this branch into |
|
🤖 Promotion conflict resolution was queued automatically for Conflicted source paths: The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed. |
✅ Develop S3 preview ready
c443e000developThe alias passed the develop bucket CORS preflight and a final live PR/SHA fence.
Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.
Subspaces 🪐 — Reddit-style communities + up/down votes 🔼
Requested by Lopu (2026-09-05): a duplicate of the subreddit feature set — user-created subspaces with branding, rules, post titles, flairs, joining, an access mode, full moderation and a per-community feed — plus upvote/downvote as a separate, focused, limited reaction kind that leaves every native Thingtime interaction (multi-emoji reactions, comments, shares, saves, polls) untouched.
Full engineering note:
PRs/subspaces-communities-and-updown-votes.md. Roadmap entry:TODO/claude-todo/25-subspaces.md.Data model (everything is a thing)
subspace— slug (rootuniqueKeyssubspaceSlug:), name, description,accesspublic|restricted|private, nsfw, rules[], flairs[], branding; acltt:all.subspace-member— one relational doc per (subspace, user): role owner|moderator|member, approved, banned/banReason/banUntil, left (uniqueKeyssubspaceMemberKey:).subspace-modlog— append-only moderator audit rows.updown— one of up|down per (user, post or comment) viauniqueKeysupdownKey:; same direction clears, the other flips; aggregated on read asvotes { up, down, score, viewerVote }.crystal.title/subspaceId/flairId(validated on every create + PATCH byapi/utils/subspaces/gate.ts), server-owned rootsubspaceMod(remove/approve/pin/lock/nsfw/spoiler) andsubspacePrivatefence.things_subspace_posts); rankings run over relational tallies in a bounded window — no denormalized score field. None of the kinds has a generic sanitizer →/api/v1/thingsrefuses them.API
/api/v1/subspaces(GET directory / POST create),/get,/update,/join,/leave,/members(GET roster / POST add·remove·approve·unapprove·ban·unban·role),/moderate(remove·approve·pin·unpin·lock·unlock·nsfw·spoiler·flair),/modlog,/feed(hot·new·top·rising·controversial, top/controversial ranges, pins lead hot/new, mods may include removed), andPOST /api/v1/things/updown. All registered in the route file + Nitro map +apiDocs+ capability manifest; rate-limit keyssubspaces.write/things.updown; PAT scopethings.updown. Contractsapi.things,api.things-feed,api.things-comment,api.things-user→ 1.2.0 (additive fields).Behaviour worth knowing: removed posts are redacted for everyone but the author and mods (never vanish mid-thread) and leave every feed; locked posts answer 423 to new comments (nested included) except from mods; bans block posting, commenting, voting and (re)joining and outlive leaving; private subspaces fence their posts on the home feed, search, trending, RSS and direct reads for non-members.
UI
/sdirectory (search, Mine, create modal —?create=1from the drawer),/s/:slug(banner/icon/join, sort tabs + range, composer locked to the subspace with title + flair, sidebar About/Rules/Flairs/Moderators, private wall),/s/:slug/mod(Queue, Members, Banned, Settings, Rules, Flairs, Log).PostCard:🪐 s/<slug>chip, flair chip, 📌/🔒/18+/PostComposer: subspace destination (locked chip or joined-subspaces select), title input, flair select; edits round-trip title/subspace/flair.Verification
test:subspaces(new),test:schemas,test:things,test:messenger,test:collections,test:storage,test:pat-scopes,test:rate-limit,test:feed,test:feed-contract,test:client-errors,test:api-capabilities,test:nav— green. Lint on every changed file: 0 errors. Fulltscdiffed against a develop-equivalent worktree: 0 new errors (the ratchet's +1 is the moved baseline, see PR note).node remix/scripts/verify-subspaces.mjsagainst the worktree stack — 94 passed, 0 failed (auth walls, create/dup/reserved slug, generic-CRUD refusal, join/leave, roles + roster visibility, title/flair posting rules incl. author PATCH vs mod-only flairs, votes on posts + comments with flip/clear/one-per-user, all five sorts, remove → redaction → approve, pin, lock 423 incl. nested, mod log, bans incl. pre-emptive + unban, restricted + private fences on feed/home/direct read, mod-added members, settings, capability manifest, cascade)./s,/s/<slug>,/s/<slug>/modhave no horizontal overflow; composer shows title +s/<slug>chip + flair select; a titled, flaired post renders chip/flair/title/▲0▼ pill; upvote paints 1 with the subspace accent; the ··· menu exposes Edit/Copy/Privacy/Delete + Remove/Pin/Lock/18+/Spoiler/Flair for the owner-mod; Pin adds the 📌 badge.Known limits (stated)
No subspace delete/transfer, no private join-request queue (mods add by username), no user flairs, removal reason via a browser prompt in the card menu.
🤖 Generated with Claude Code
Round 2 — every stated limit built, plus what a subreddit needs to actually run
Lopu (2026-09-05): "Please build these 😮 and build anything else required for subspaces to function completely." Built slice by slice (each with two adversarial reviews and a fix pass), then a full live walk and a browser pass. Master spec:
~/Claude/Loops/subspace/ROUND2-SPEC.md(canonical names); engineering note:PRs/subspaces-communities-and-updown-votes.md.What's new
POST /api/v1/subspaces/transfer(owner → active member; previous owner becomes a moderator and may leave) andPOST /api/v1/subspaces/delete(owner retypes the slug; posts are released as plain posts through the accounted bulk updater, members/modlog/reports cascade, former mods are notified). Mod page Danger zone with confirm modals.joinfiles apendingmember row, modsaccept/deny, requester cancels withleave) and posting-approval requests for restricted ones (request-approval→ modsapprove). New Requests mod tab with badge counts; pending rows are excluded from counts, the composer's destination list andcanPost.userFlair(self or mods),authorFlairon every post and comment projection (one batcheduniqueKeyslookup per page, comments resolve through the root post's subspace), Your flair sidebar card, mod page editors.removalReasonspresets,moderate remove { reason | reasonId, ruleIndex }, the author is notified with the reason by the subspace's mod team (never the individual moderator);RemoveModal(rules / presets / custom, "also lock", "also ban"),BanModal,ReportModal, transfer/delete confirms — nowindow.prompt/confirmremains in the subspace UI.subspace-report(one open report per reporter per post, comments resolve to the root),POST /api/v1/subspaces/report,GET/POST /api/v1/subspaces/reportsgrouped by post (mods),subspaceMod.reportCount+openReportCountfor mods, auto-settled by remove/approve, Reports mod tab, "Report to moderators 🚩" in the card menu.subspace-*types (join-request, join-accepted, post-removed, report, role, ban) registered in the enum, categories, prefs matrix, bell copy, email templates and CTA; mod-queue types default to email opt-in; bulk emits dedupe against unread copies; punitive rows carry the mod team as actor.GET /api/v1/things/feed?scope=subspaces(+ the "🪐 My subspaces" chip on /feed, persisted per browser),GET /api/v1/subspaces?sort=new|members|active(bounded windows, private activity hidden from non-members, newsubspaces.listrate limit), Explore Popular subspaces strip, Search Subspaces section.GET /api/v1/things?id=&commentSort=top|new|old, level-1 sorted server-side; fresh comments stay visible under a sort), guest nudges on every action, edge cases pinned by tests (bans drop pending requests, transfer refuses banned/pending targets, demoted mods lose queue access, private posts never reach RSS/trending, deleting a post clears its reports).develop(301 commits): the subspace notification family now rides develop's sharednotificationCore(META, verbs,/s/<slug>click-through) beside its new system notes;api.notifications-list→ 1.3.0,api.notifications-settings→ 1.2.0 with every pin updated.e8d81b969integrates them. Round-2 files win where they conflicted and every review improvement is re-applied on top:subspaceModHoldsPost(a PATCH can't move a removed/locked post out of its subspace → 403),resolveRootPost→{ root, truncated }behind a 512-hop rail with the comment/vote gate and report path failing closed (409) on an unresolved chain,membershipFor(authoritative single-row lookup on a roles-snapshot miss) on every write path,updownVotesOfviaupdownTalliesFor. Banned-firstcanPostand kick-clears-approved were already in round 2. Post-merge: ten unit families green,verify-subspaces.mjs486/486, lint clean.cb9d095d5) — 27 more commits landed ondevelop(hidden-link keys, custom audience groups, the profile email-visibility setting). Every conflict was "both sides added something here" and keeps both: theViewercarriessubspaceRolesalongsidegroupIds/linkKeysandwithFriendIdsloads all three in one pass; the post projection keeps develop's owner-onlylinkKeyand this branch's moderator-removal body redaction; the card renders the subspace Remove/Report modals beside the custom-audience modal; the composer keeps both its subspace state/payload block and the custom-acl one;things.getsendsid,commentSortandkeyon one query; the capability pins keep this branch's numbers and add develop'sapi.users-profile1.1.0. Re-verified: subspaces 53, things 32, feed 46, api-capabilities 19, schemas 181, notifications 31, rate-limit 29, storage 17, moderation 53, acl 6 green;verify-subspaces.mjs486/486; no tsc errors in the merged files; lint clean.Verification (round 2)
node remix/scripts/verify-subspaces.mjs— 486 passed, 0 failed, sections A–T (re-run after the develop merge: still 486/486).tscshows no new errors in touched files (the remaining hits are pre-existing device-schema descriptions inregistry.ts, the duplicateheadersinapiDocs.ts,nitro.config.ts).Still out of scope (stated)
Crossposting, per-subspace wikis/sidebar widgets, karma totals, modmail threads; deny of a join/posting request does not notify the requester (Reddit parity); the card's comment sort is a per-read view, not a persisted preference.