Skip to content

feat(subspaces): Reddit-style communities + up/down votes as a focused reaction kind - #649

Merged
lopugit merged 33 commits into
developfrom
claude/subspace-communities
Sep 7, 2026
Merged

feat(subspaces): Reddit-style communities + up/down votes as a focused reaction kind#649
lopugit merged 33 commits into
developfrom
claude/subspace-communities

Conversation

@lopugit

@lopugit lopugit commented Sep 5, 2026

Copy link
Copy Markdown
Owner

✅ Develop S3 preview ready

The 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 (root uniqueKeys subspaceSlug:), name, description, access public|restricted|private, nsfw, rules[], flairs[], branding; acl tt:all.
  • subspace-member — one relational doc per (subspace, user): role owner|moderator|member, approved, banned/banReason/banUntil, left (uniqueKeys subspaceMemberKey:).
  • subspace-modlog — append-only moderator audit rows.
  • updown — one of up|down per (user, post or comment) via uniqueKeys updownKey:; same direction clears, the other flips; aggregated on read as votes { up, down, score, viewerVote }.
  • Posts: crystal.title / subspaceId / flairId (validated on every create + PATCH by api/utils/subspaces/gate.ts), server-owned root subspaceMod (remove/approve/pin/lock/nsfw/spoiler) and subspacePrivate fence.
  • One new partial index (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/things refuses 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), and POST /api/v1/things/updown. All registered in the route file + Nitro map + apiDocs + capability manifest; rate-limit keys subspaces.write / things.updown; PAT scope things.updown. Contracts api.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

  • /s directory (search, Mine, create modal — ?create=1 from 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+/⚠️ badges, title h2, "Removed by moderators" notice, the ▲ score ▼ pill beside the react button on posts and (compact) comments with optimistic updates, moderator menu group + flair submenu.
  • PostComposer: subspace destination (locked chip or joined-subspaces select), title input, flair select; edits round-trip title/subspace/flair.
  • Drawer: Feed ▸ Subspaces + a Subspaces group; Settings → Subspaces 🪐 (vote pills on posts/comments, default sort).

Verification

  • Unit: 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. Full tsc diffed against a develop-equivalent worktree: 0 new errors (the ratchet's +1 is the moved baseline, see PR note).
  • Live API walk: node remix/scripts/verify-subspaces.mjs against 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).
  • Browser (in-app pane, worktree stack, desktop 2253px + 375px): full-width Reddit-style layout — header spans the viewport, post column fills beside a sticky 340px sidebar, sidebar stacks below the posts on phones, directory flows a responsive card grid; /s, /s/<slug>, /s/<slug>/mod have 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.
  • TESTING.md gained "Subspaces" and "Up/down votes" checklists.

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

  • Ownership transfer + deletePOST /api/v1/subspaces/transfer (owner → active member; previous owner becomes a moderator and may leave) and POST /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.
  • Join requests for private subspaces (join files a pending member row, mods accept/deny, requester cancels with leave) and posting-approval requests for restricted ones (request-approval → mods approve). New Requests mod tab with badge counts; pending rows are excluded from counts, the composer's destination list and canPost.
  • User flairs — per-subspace templates + self-assign / custom-text switches, member action userFlair (self or mods), authorFlair on every post and comment projection (one batched uniqueKeys lookup per page, comments resolve through the root post's subspace), Your flair sidebar card, mod page editors.
  • Removal reasons + modalsremovalReasons presets, 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 — no window.prompt/confirm remains in the subspace UI.
  • Reports — kind subspace-report (one open report per reporter per post, comments resolve to the root), POST /api/v1/subspaces/report, GET/POST /api/v1/subspaces/reports grouped by post (mods), subspaceMod.reportCount + openReportCount for mods, auto-settled by remove/approve, Reports mod tab, "Report to moderators 🚩" in the card menu.
  • Notifications — six 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.
  • DiscoveryGET /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, new subspaces.list rate limit), Explore Popular subspaces strip, Search Subspaces section.
  • Completeness sweep — per-card comment sort (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).
  • Merged with develop (301 commits): the subspace notification family now rides develop's shared notificationCore (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.
  • Branch integration (2026-09-06) — the repo automation pushed two develop→branch merges and three "repository review improvements" commits to this branch while round 2 was in flight; merge commit e8d81b969 integrates 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, updownVotesOf via updownTalliesFor. Banned-first canPost and kick-clears-approved were already in round 2. Post-merge: ten unit families green, verify-subspaces.mjs 486/486, lint clean.
  • Second develop catch-up (2026-09-07, cb9d095d5) — 27 more commits landed on develop (hidden-link keys, custom audience groups, the profile email-visibility setting). Every conflict was "both sides added something here" and keeps both: the Viewer carries subspaceRoles alongside groupIds/linkKeys and withFriendIds loads all three in one pass; the post projection keeps develop's owner-only linkKey and 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.get sends id, commentSort and key on one query; the capability pins keep this branch's numbers and add develop's api.users-profile 1.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.mjs 486/486; no tsc errors in the merged files; lint clean.

Verification (round 2)

  • node remix/scripts/verify-subspaces.mjs486 passed, 0 failed, sections A–T (re-run after the develop merge: still 486/486).
  • Unit families after the merge: subspaces 52, schemas 169, api-capabilities 19, feed 39, nav 11, storage 17, rate-limit 16+13, things 32, notifications 31, capabilities 5 — all green; lint 0 errors on touched files; full tsc shows no new errors in touched files (the remaining hits are pre-existing device-schema descriptions in registry.ts, the duplicate headers in apiDocs.ts, nitro.config.ts).
  • Browser pass (in-app pane + Playwright driver, desktop 1440/1549px and 375px, as owner / member / pending requester / guest): every page, tab and modal above; four 375px defects fixed (action row wrap, modal header vs close button, access-select option text, mod header wrap).

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.

Lopu sync resolver and others added 4 commits September 5, 2026 18:45
…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>
@lopugit

lopugit commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

🧹 Develop S3 preview removed

The 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.

@lopugit lopugit added the preview: develop building tt-preview-state:v1:develop:building label Sep 5, 2026
@lopugit
lopugit temporarily deployed to develop-pr-649 September 5, 2026 08:59 Destroyed
@lopugit lopugit added last preview built 07/09 21:01 AEST #649 tt-pv1:649:develop:c443e000e279d96a1b87e67e868f2575fad3d9ac:1788778881746 preview: develop ready tt-preview-state:v1:develop:ready and removed preview: develop building tt-preview-state:v1:develop:building labels Sep 5, 2026
Lopu sync resolver and others added 2 commits September 5, 2026 19:07
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>
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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
@lopugit
lopugit temporarily deployed to develop-pr-649 September 5, 2026 10:37 Destroyed
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>
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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>
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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
Lopu sync resolver and others added 3 commits September 5, 2026 21:35
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>
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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
Lopu sync resolver and others added 2 commits September 5, 2026 23:13
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>
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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
github-actions Bot added a commit that referenced this pull request Sep 5, 2026
…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
@github-actions github-actions Bot added lopu: conflicting GitHub reports merge conflicts for the current PR snapshot lopu: overlapping files This PR changes files also changed by another open PR labels Sep 5, 2026
Lopu sync resolver and others added 2 commits September 6, 2026 22:04
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>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected an out-of-date PR branch

Status: 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 12:38 UTC (UTC+00:00) 2026-09-06 05:38 PDT (UTC-07:00) 2026-09-06 22:38 AEST (UTC+10:00)
Estimated finish 2026-09-06 12:58 UTC (UTC+00:00) 2026-09-06 05:58 PDT (UTC-07:00) 2026-09-06 22:58 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 23
Repository Conflicting 1
Repository Out-of-date with target 6
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 12:38 UTC — Detected that develop needs to be merged into claude/subspace-communities; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected an out-of-date PR branch

Status: 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-06 12:55 UTC (UTC+00:00) 2026-09-06 05:55 PDT (UTC-07:00) 2026-09-06 22:55 AEST (UTC+10:00)
Estimated finish 2026-09-06 13:15 UTC (UTC+00:00) 2026-09-06 06:15 PDT (UTC-07:00) 2026-09-06 23:15 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 23
Repository Conflicting 1
Repository Out-of-date with target 6
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 16
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 12:55 UTC — Detected that develop needs to be merged into claude/subspace-communities; assigning the exact snapshot to the resolver queue.

Lopu sync resolver and others added 3 commits September 6, 2026 23:06
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>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🦄 Lopu review — claude/subspace-communities @ e8d81b9 vs develop @ 69f62ed

I read this one properly rather than skimming 16.5k lines: the gate, the vote write path, the things.ts integration, and the authz on every new endpoint. Approving as-is — no changes were justified, so I left the worktree clean rather than manufacturing churn.

Checks & CodeQL. gh pr checks shows nothing failing, cancelled, or timed out. The trusted CodeQL snapshot for this exact head has zero open alerts, so there is nothing to fix and nothing to disposition.

Conventions — verified, not assumed.

  • All 13 /api/v1/subspaces* routes plus /api/v1/things/updown are registered in all three required places (route file, routeModules import map, apiEndpointDocs — including the bare /api/v1/subspaces at apiDocs.ts:9394). Nitro won't 404 them.
  • Child data is relational throughout — votes, memberships, mod-log rows, reports and slug tombstones are each their own atomic doc. Every read path aggregates in batches (updownTalliesFor, loadAuthorFlairs, loadOpenReportCounts, loadSubspaceEmbeds), so there's no N+1 on the feed or the mod queue.
  • things_subspace_posts is partial on the string ref, which matches the /s/<slug> newest-first candidate window and keeps non-subspace posts out of the index.
  • The new plumbing kinds are correctly non-billable while the subspace doc itself stays billable — consistent with Messenger communities.

Security — the gate holds. The posting gate runs on create and on any PATCH touching subspaceId/flairId, so the generic things route can't smuggle a post past a ban, a private wall, or a mod-only flair. Votes re-check visibility, the PAT sandbox grant, and the ban gate on every cast.

The part I liked most is the truncation handling. resolveRootPost separates "couldn't reach the top" from "there's no post up there", and truncatedThreadFail() denies instead of reading an unresolved chain as "not in a subspace". That's exactly the difference between a ban that holds at every comment depth and one that quietly stops applying below the hop rail. membershipFor draws the same line correctly: read surfaces may use the bounded viewer snapshot because each fails closed on a miss, while writes fall back to the indexed lookup — because the row a truncated snapshot dropped could be the ban that denies the write.

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. canSeeSubspaceActivity withholds recentPostCount, and subspaceFeedClauses fences private posts out of every list. Consistent across listMembers, listSubspaces and subspaceFeed. Owner-only lifecycle actions require both the member row's owner role and the subspace doc's ownerId, so a half-applied transfer can't leave two people holding the crown 👑

Validation. Dependencies aren't installed in the review worktree, so I ran the pure suites with a scratch tsx loader:

Suite Result
test:subspaces (subspaces + updownCore + components/Subspaces) 53 / 53 pass
app/schemas/*.test.ts 164 / 164 pass
app/components/Feed/*.test.ts 39 / 39 pass

test:api-capabilities, test:rate-limit and part of test:notifications need nitro/h3 / mongodb and can't run in this worktree — that's an environment limit, not a PR failure, and CI is green on this head.

Two notes, neither a defect, neither changed:

  1. orderCommentPage(entries, null, limit) returns entries.slice(-limit), which would yield the whole level if limit were ever 0. The one call site passes a bounded page constant and this reproduces the pre-existing behaviour exactly — not a regression.
  2. activatePendingRequests notifies the first MAX_NOTIFIED_REQUESTERS but flips every pending row, so it can admit members past MAX_SUBSPACE_MEMBERSHIPS_PER_USER. The comment says that's intended and the cap is soft, so I left it.

Nice work — the gate.ts / subspaces.ts cycle-free split and the pure *Core.ts modules are what made a diff this size reviewable at all.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🛰️ Lopu review — head e8d81b96 vs develop @ 69f62ed6

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: subspaces 53 · schemas 169 · feed-contract 37 · things 32 · notifications 31 · feed 39 · api-capabilities 19 · storage · collections · rate-limit · pat-scopes · nav.

What I went after

I attacked the access-control surface first, since that's where a communities feature fails dangerously.

  • The private fence is a stamp, so I attacked the stamp. Posts are fenced by a root subspacePrivate written at create time — so flipping a public subspace to private should leave the existing posts unfenced. It doesn't: subspaces.ts:915-918 re-stamps the whole subspace on an access change, $set inbound and $unset outbound (so public → restricted correctly un-fences). That's the bug I most expected to find, and it's already handled.
  • The snapshot/authority split is the right shape. loadViewerSubspaceRoles is a bounded snapshot, so a miss can't mean "not a member". Read paths use it alone and every one fails closed; write paths that would fail open go through membershipFor and its authoritative findOne. Exactly the right invariant — and gate.ts:140-154 writes down why, which is what makes it maintainable.
  • PATCH-laundering is closed. Moving a post between subspaces drops subspaceMod with it, so without a guard an author could launder a removed/locked post clean by PATCHing it out and back. subspaceModHoldsPost answers 403 for exactly removal/lock and lets pin/nsfw/spoiler travel. The replaceCrystal (PUT) path trips the same guard.
  • Truncation ≠ absence. resolveRootPost returning {root, truncated} with a 409 on an unresolved chain is the difference between a ban that holds at every depth and one that quietly stops applying below the rail.

Also verified: the fence on every read surface individually (feed, listUserPosts with the owner exempted, search, trending, RSS, subspaceFeed, direct canView); idempotent remove (no second bell on a retry or a two-mod race); punitive bells from the mod team rather than the individual mod; requireOwner demanding both the member row and the subspace ownerId. On the ranked subspaceFeed path the page re-fetch drops the audience clause, but canView still filters and the projection still redacts — defense in depth holds through the race window.

Follow-ups for the record — none blocking

  1. Unbounded vote-row load in page projections. resolveRelated fetches every updown row for every doc on a page with no limit. updownVotesOf exists specifically to avoid this — its own comment says "one vote on a 100k-vote post would read 100k docs to answer with four numbers" — and uses $group. The single-target path has the mitigation; the page path doesn't. It inherits the existing reaction pattern (reaction totals are unbounded too), so it's not a regression, and the fix is routing the page projection through the batched $group updownTalliesFor already provides. Worth doing before a subspace gets its first genuinely viral post — up/down votes will be the highest-volume relational kind here by a wide margin. Left alone deliberately: that's an architectural change, not a review-time edit.
  2. Root-hop rails disagreeMAX_ROOT_HOPS = 16 (read) vs MAX_ROOT_POST_HOPS = 512 (write). Past 16 levels a comment silently loses its authorFlair/subspace embed. Fails closed and cosmetic, but the two constants deserve a note saying the difference is intentional.
  3. resolveTargetUserId trusts a raw userId without checking the user exists. Looks intentional — it's how pre-emptive bans by id work — and it's moderator-gated, so the blast radius is one junk row. Worth a comment so nobody later "fixes" it into breaking pre-emptive bans.
  4. Tiny: in canView, !isActiveSubspaceMember(m) && !canModerateSubspace(m)canModerate already implies isActiveMember, so the second clause can't change the result.

On the shape of it

The relational modelling follows FUNDAMENTALS.md faithfully: one atomic thing per (subspace, user) and per (user, target), deduped structurally through the root uniqueKeys namespace rather than by application-level check, tallies aggregated on read, and no denormalized score field — the right call and the harder one. One new partial index. No new kind has a generic crystal sanitizer, so /api/v1/things refuses them and the dedicated utils stay the only writers.

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 (SubspaceModPage.tsx alone is 1,924 lines), which I sampled. The server-side fences are the security boundary and they hold independently of the UI, so I'm comfortable with that split.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🛰️ Lopu review — full head e8d81b9 vs base 69f62ed (87 files, +16,543 / −201)

Verdict: approve. No blocking defects found, no changes made.

What I checked

The fencing core (subspaces/gate.ts and its integration into things.ts
canView, createThing, getFeed, listUserPosts, deleteThing,
toPublicPosts), the full 2,218-line subspace service, the up/down vote kind,
all 14 new endpoints, and index coverage for every new query shape.

What holds up

  • The snapshot-vs-authoritative split is applied correctly at every call
    site.
    membershipFor() treats a miss in the bounded roles snapshot as "not
    in the snapshot"
    , and every path that would fail open on a missing row —
    posting gate, interaction gate, subspaceFeed, reportPost — goes through
    it, while the paths that fail closed use the snapshot alone. That
    distinction is the crux of the design and it isn't dropped anywhere.
  • truncated is never read as "no subspace." Every consumer of
    resolveRootPost 409s on an unresolvable chain, so a ban or a post lock
    can't be escaped by nesting below the rail.
  • The private fence survives access changes. updateSubspace re-stamps /
    unsets subspacePrivate across existing posts on a private⇄non-private flip,
    so canView and subspaceFeedClauses can't drift from the live access mode
    — and the pending join / posting-approval queues are drained in the same
    transition rather than stranded.
  • Privilege boundaries are tight. role is owner-only and can't reach
    owner; a non-owner mod can't ban/remove/demote another mod; the two self
    actions verify the caller is the target by both userId and username;
    and moderatePost resolves the subspace from the post before gating, so
    a caller can't present a subspace they moderate to act on a post elsewhere.
  • No N+1 and no unindexed query. Tallies, author flairs, report counts,
    subspace embeds and comment-root resolution are all one batched read per
    page. The new things_subspace_posts partial index serves every /s/<slug>
    sort; the existing {thingtime, ownerId, …}, {targetId, thingtime, …} and
    uniqueKeys indexes cover the rest.

Two tradeoffs worth having on the record (not defects)

  1. One report row per (post, reporter) means a second report against a
    different comment on the same post overwrites the earlier commentId.
    Deliberate and documented as anti-spam; the queue groups by post regardless.
  2. The read loaders (get/feed/members/modlog/reports) carry no rate
    limit while subspaces.list does. That matches the existing convention
    exactly — things/feed, trending, saved, user and rss are likewise
    unlimited, and only the search-shaped reads carry a ceiling, which is
    precisely why subspaces.list has one.

Validation

Deps aren't installed in the review worktree, so I ran the changed unit tests
on Node 22's TypeScript type-stripping with a local ~/ alias resolver hook
(kept outside the repo — no files touched):

# tests 156   # pass 156   # fail 0

Two further changed test files need packages absent from this environment
(rateLimit/config.test.tsmongodb, docs/apiCapabilities.test.ts
nitro); both are green in the PR's own CI, which I confirmed live —
gh pr checks: 24 pass / 62 skip / 0 fail.

CodeQL: the trusted snapshot for this head is empty. No alerts to fix or
dispose.

I verified all 14 endpoints are registered in all three required places (route
file · Nitro import map · apiDocs.ts).

— 🌈 Lopu

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected merge conflicts

Status: 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 05:40 UTC (UTC+00:00) 2026-09-06 22:40 PDT (UTC-07:00) 2026-09-07 15:40 AEST (UTC+10:00)
Estimated finish 2026-09-07 06:00 UTC (UTC+00:00) 2026-09-06 23:00 PDT (UTC-07:00) 2026-09-07 16:00 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 23
Repository Conflicting 3
Repository Out-of-date with target 0
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 18
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 6
This resolver batch Currently resolving 0
This resolver batch Waiting 6
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 28 changed files are also touched by #10, #295, #557, #564, #590, #595, #602, #607, #611, #631, #638, #659, +3 more.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 05:40 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ✅ 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 06:16 UTC (UTC+00:00) 2026-09-06 23:16 PDT (UTC-07:00) 2026-09-07 16:16 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 27
Repository Conflicting 3
Repository Out-of-date with target 8
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 18
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 3
This resolver batch Currently resolving 1
This resolver batch Waiting 0
This resolver batch Finished 2

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 25 changed files are also touched by #10, #295, #557, #564, #595, #602, #607, #611, #631, #638, #659, #660, +3 more.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 05:42 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.
  • 05:45 UTC — The immutable head/base selection is reserved; waiting for its worker job to enter the serialized lane.
  • 05:46 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 05:56 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 06:07 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 06:16 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected merge conflicts

Status: 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 05:51 UTC (UTC+00:00) 2026-09-06 22:51 PDT (UTC-07:00) 2026-09-07 15:51 AEST (UTC+10:00)
Estimated finish 2026-09-07 06:11 UTC (UTC+00:00) 2026-09-06 23:11 PDT (UTC-07:00) 2026-09-07 16:11 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 26
Repository Conflicting 4
Repository Out-of-date with target 8
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 21
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: 28 changed files are also touched by #10, #295, #557, #564, #590, #595, #602, #607, #611, #631, #638, #659, +6 more.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 05:51 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.

… 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>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ↪️ 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 08:08 UTC (UTC+00:00) 2026-09-07 01:08 PDT (UTC-07:00) 2026-09-07 18:08 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 28
Repository Conflicting 5
Repository Out-of-date with target 8
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 20
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 1
This resolver batch Currently resolving 0
This resolver batch Waiting 0
This resolver batch Finished 1

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 06:52 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.
  • 06:54 UTC — The immutable head/base selection is reserved; waiting for its worker job to enter the serialized lane.
  • 06:55 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 07:06 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:17 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:27 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:38 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:49 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 07:59 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 08:08 UTC — The worker published its verified result, but GitHub reports the latest PR state as conflicting again; Lopu will rediscover it automatically.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🔭 Lopu review — the red CodeQL check is not yours

I root-caused the one failing check on this PR. Nothing in this branch's diff causes it, and I made no changes to this branch.

What actually happened

The failing CodeQL context isn't this repo's analysis job — it's the aggregate check posted by the github-advanced-security app (check run 101637358172), conclusion timed_out, with no rule, location or output.

Both of our own analyses for the analyzed revision succeeded and uploaded:

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:05Zone second before the javascript-typescript analysis landed at 06:01:06Z.

Why this is structural, not bad luck

Upload skew (actionsjavascript-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

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu detected merge conflicts

Status: 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 07:20 UTC (UTC+00:00) 2026-09-07 00:20 PDT (UTC-07:00) 2026-09-07 17:20 AEST (UTC+10:00)
Estimated finish 2026-09-07 07:40 UTC (UTC+00:00) 2026-09-07 00:40 PDT (UTC-07:00) 2026-09-07 17:40 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 29
Repository Conflicting 4
Repository Out-of-date with target 6
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 22
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 7
This resolver batch Currently resolving 0
This resolver batch Waiting 7
This resolver batch Finished 0

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 07:20 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu live PR update

Status: ✅ 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)

Moment UTC Los Angeles Melbourne
Updated 2026-09-07 11:19 UTC (UTC+00:00) 2026-09-07 04:19 PDT (UTC-07:00) 2026-09-07 21:19 AEST (UTC+10:00)

Los Angeles and Melbourne use their real IANA time zones, so PDT/PST and AEST/AEDT offsets change automatically.

Lopu queue and PR pulse

Scope Metric Count
Repository Open PRs 28
Repository Conflicting 3
Repository Out-of-date with target 0
Repository GitHub state unknown 4
Repository Part of an open stack 2
Repository Touch files changed by another open PR 19
Repository Target a non-root branch without an open parent PR 0
This resolver batch Admitted snapshots 3
This resolver batch Currently resolving 1
This resolver batch Waiting 0
This resolver batch Finished 2

Related PR context

  • Stack: No open parent or child PR currently links to this branch.
  • Target: develop is a repository root/integration branch.
  • Changed-file overlap: No changed paths overlap another open PR in this snapshot.

Exact branch pair: developclaude/subspace-communities.

Timeline

  • 07:49 UTC — Detected conflicts between develop and claude/subspace-communities; assigning the exact snapshot to the resolver queue.
  • 08:45 UTC — Still safely queued behind earlier admitted Lopu work; no duplicate resolver was spawned.
  • 08:56 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 09:06 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 09:17 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 09:28 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 09:38 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 09:49 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 10:00 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 10:11 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 10:21 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 10:32 UTC — 10-minute check-in: still working — Waiting in Lopu's serialized PR-management lane.
  • 10:34 UTC — Merging the target branch into the PR branch.
  • 10:39 UTC — Using repository context to resolve the conflict semantics.
  • 10:42 UTC — Rebuilding Graphify structure and semantic context.
  • 10:53 UTC — 10-minute check-in: still working — Rebuilding Graphify structure and semantic context.
  • 10:58 UTC — Publishing the verified branch update.
  • 10:59 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.
  • 11:09 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.
  • 11:19 UTC — The resolver worker completed successfully and GitHub now reports this PR as mergeable.

Technical run details — optional; this comment is the human-facing source of truth.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged develop into claude/subspace-communities — conflicts auto-resolved by Lopu's PR manager.

Conflicted files:

  • FUNDAMENTALS.md
  • graphify-out/snapshots/v1/ff44c56dbc4ae2c598506d2f1fc765839e5f1f6cc68c2757fa5851ef71830c07/141d176fa382df8ef1677a27682eae677ac66220fe46b6fc0b9815d9b9f3315d/cost.json
  • graphify-out/snapshots/v1/ff44c56dbc4ae2c598506d2f1fc765839e5f1f6cc68c2757fa5851ef71830c07/141d176fa382df8ef1677a27682eae677ac66220fe46b6fc0b9815d9b9f3315d/graph.json
  • remix/CHANGELOG.md

graphify-out/ was reset wholesale to the develop side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI).

Rebuilt and committed the merged code graph with graphify update .. LLM semantic extraction was attempted afterward but failed; the valid structural result was preserved (see the workflow log).

Please review the merge commit before relying on it.

github-actions Bot and others added 2 commits September 7, 2026 10:41
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
@lopugit

lopugit commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-649-subspace-communities--to-main at exact base main (64c0a33c91c62d5c0b6db8fd673b121b10ff4cf0).

Conflicted source paths: remix/app/api/utils/things/things.ts, remix/app/components/Feed/PostCard.tsx, remix/app/components/Feed/PostComposer.tsx, remix/app/hooks/useApi.tsx.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ Lopu did not complete the promotion resolution before a reviewable PR was fully published.

Branch: promote/pr-649-subspace-communities--to-main · base: main at f1e7eeb4ddd0e2e7264344171fd90223000e8fa9 · plan: c7251bf6624d4eac4d6e7028746a01a0e7ebc0600c993e8b7cd2e0ef013b32da.

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.

@lopugit

lopugit commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Promoted to main as a scoped backport in #688 (merged, f1e7eeb4d).

Merging this branch into main directly would have carried all 77 develop commits — including the 8 features still gated behind their own promotion PRs — because the branch had absorbed two develop merges. #688 instead applies only this feature's own contribution onto main and drops the parts that belong to hidden links / custom audiences (#680), which main does not have yet.

@lopugit

lopugit commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-649-subspace-communities--to-main at exact base main (f1e7eeb4ddd0e2e7264344171fd90223000e8fa9).

Conflicted source paths: remix/app/api/utils/things/things.ts, remix/app/components/Feed/PostCard.tsx, remix/app/components/Feed/PostComposer.tsx, remix/app/hooks/useApi.tsx.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-promotion-paused Automatic promotion resolution paused for this exact snapshot last preview built 07/09 21:01 AEST #649 tt-pv1:649:develop:c443e000e279d96a1b87e67e868f2575fad3d9ac:1788778881746 preview: develop removed tt-preview-state:v1:develop:removed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant