diff --git a/INTENT.md b/INTENT.md
index 9e8b673..0cdbfc9 100644
--- a/INTENT.md
+++ b/INTENT.md
@@ -1,6 +1,6 @@
---
repo: coulomb-social
-updated: "2026-08-10"
+updated: "2026-10-06"
status: active
---
@@ -27,6 +27,26 @@ This repository is the **owning home** for the rebuild:
Binky Hedgehog GmbH is **client #1** of its own platform (DR-2 B). The app is not a Core Hub extension; it is a normal client-facing business application.
+## Current focus — User-reviewed use-case off-ramp
+
+Find and describe the use cases actually used in the current coulomb.social
+implementation, rebuild them on our ecosystem, and move them off Bubble one
+reviewed cohort at a time. Start with a nominated spaces/pages workflow under
+the accepted stage-1 cut; captured feature/type inventories are discovery inputs,
+not proof of complete current use-case behavior.
+
+The first milestone is **ready for user review of the first use-case transfer**:
+the user can recognize migrated work, continue creating/editing/organizing pages,
+share with the intended readers, and trust persistence and recovery. A comparison
+walkthrough records old/new steps and gaps; the user accepts or rejects each
+journey for transfer. Broader off-ramp continues by observing, describing,
+rebuilding, comparing and transferring subsequent use cases. Full Bubble
+retirement follows explicit disposition of all retained use cases.
+
+Execution: `workplans/CSOC-WP-0008-production-transfer-acceptance.md`.
+Product backlog: `docs/usecases/catalog.md`.
+User review: `docs/usecases/first-transfer-review.md`.
+
## Core Idea
> Exit bubble.io by duplicating the product surface into an agentic, code-owned stack — not by bolting more low-code onto a stalled base.
diff --git a/SCOPE.md b/SCOPE.md
index 9794eda..570ab5a 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -36,9 +36,10 @@ filesystem tenant namespace or a database-wide concurrency guarantee.
## Practical limits
-- Space visibility has a tenant-wide bootstrap fallback until any tenant
- memberships exist. Membership roles are stored but viewer/editor enforcement
- and Bubble privacy parity are incomplete. Imported mappings add/update grants;
+- Space reads now require explicit membership or a creator grant. Viewer
+ memberships are read-only across product mutation routes and service writes;
+ owner/member grants permit writes. Sharing-management UI and exact Bubble
+ privacy parity remain incomplete. Imported mappings add/update grants;
replacement does not revoke old grants. Review access before a real cohort import.
- Copy/transfer handles relative Visual files, not arbitrary body-linked assets,
incoming page links, graph identity or Bubble parent/chunk semantics. Visual
diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md
index db1f046..6887a42 100644
--- a/WORK-RECORDS.md
+++ b/WORK-RECORDS.md
@@ -55,10 +55,12 @@
| task | CSOC-WP-0008-T01 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T02 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T03 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
-| task | CSOC-WP-0008-T04 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
+| task | CSOC-WP-0008-T04 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T05 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T06 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T07 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
+| task | CSOC-WP-0008-T08 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
+| task | CSOC-WP-0008-T09 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0002 | closed | green | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md |
diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py
index 321865a..e00d957 100644
--- a/coulomb_social/apps/spaces/services.py
+++ b/coulomb_social/apps/spaces/services.py
@@ -12,22 +12,11 @@ from .models import Space, SpaceMembership
def spaces_for_member(member: Member | None) -> QuerySet[Space]:
- """Spaces visible to this member (same tenant, active).
-
- Explicit SpaceMembership grants access; if none exist for the tenant yet,
- all active tenant spaces are listed (operator-seeded MVP). Once any
- memberships exist for the tenant, only membership + created_by apply.
- """
+ """Only active tenant spaces with an explicit membership or creator link."""
if member is None:
return Space.objects.none()
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True)
- tenant_has_memberships = SpaceMembership.objects.filter(
- space__tenant_id=member.tenant_id
- ).exists()
- if not tenant_has_memberships:
- return qs.order_by("title", "slug")
-
return (
qs.filter(Q(memberships__member=member) | Q(created_by=member))
.distinct()
@@ -41,6 +30,21 @@ def get_space_for_member(member: Member | None, slug: str) -> Space | None:
return spaces_for_member(member).filter(slug=slug).first()
+def can_write_space(member: Member | None, space: Space) -> bool:
+ """Require an exact tenant/space grant, not a same-slug visibility match."""
+ if member is None or member.tenant_id != space.tenant_id or not space.is_active:
+ return False
+ membership = SpaceMembership.objects.filter(space=space, member=member).first()
+ if membership is not None:
+ return membership.role in {SpaceMembership.Role.OWNER, SpaceMembership.Role.MEMBER}
+ return space.created_by_id == member.pk
+
+
+def require_space_write(member: Member | None, space: Space) -> None:
+ if not can_write_space(member, space):
+ raise pageops.PageOpsError("You have read-only access to this space")
+
+
@transaction.atomic
def create_space(
member: Member,
@@ -90,8 +94,7 @@ def update_space(
abstractor: str | None = None,
visual: str | None = None,
) -> Space:
- if get_space_for_member(member, space.slug) is None:
- raise pageops.PageOpsError("Space not found")
+ require_space_write(member, space)
if title is not None:
space.title = title.strip() or space.title
if abstractor is not None:
@@ -116,8 +119,7 @@ def update_space(
@transaction.atomic
def archive_space(member: Member, space: Space) -> Space:
- if get_space_for_member(member, space.slug) is None:
- raise pageops.PageOpsError("Space not found")
+ require_space_write(member, space)
space.is_active = False
space.save(update_fields=["is_active", "updated_at"])
try:
@@ -142,8 +144,7 @@ def put_space_page(
visual: str = "",
body: str = "",
) -> pageops.PageDoc:
- if get_space_for_member(member, space.slug) is None:
- raise pageops.PageOpsError("Space not found")
+ require_space_write(member, space)
return pageops.write_page(
space.slug,
page_slug,
@@ -163,11 +164,9 @@ def copy_space_page(
dest_space: Space | None = None,
title: str | None = None,
) -> pageops.PageDoc:
- if get_space_for_member(member, space.slug) is None:
- raise pageops.PageOpsError("Source space not found")
+ require_space_write(member, space)
target = dest_space or space
- if get_space_for_member(member, target.slug) is None:
- raise pageops.PageOpsError("Destination space not found")
+ require_space_write(member, target)
return pageops.copy_page(
space.slug,
page_slug,
@@ -185,10 +184,8 @@ def transfer_space_page(
*,
dest_slug: str | None = None,
) -> pageops.PageDoc:
- if get_space_for_member(member, space.slug) is None:
- raise pageops.PageOpsError("Source space not found")
- if get_space_for_member(member, dest_space.slug) is None:
- raise pageops.PageOpsError("Destination space not found")
+ require_space_write(member, space)
+ require_space_write(member, dest_space)
return pageops.transfer_page(
space.slug,
page_slug,
diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py
index d089266..a8cd7c6 100644
--- a/coulomb_social/apps/spaces/views.py
+++ b/coulomb_social/apps/spaces/views.py
@@ -32,6 +32,7 @@ from .content import (
from .models import Space
from .services import (
archive_space,
+ can_write_space,
copy_space_page,
create_space,
get_space_for_member,
@@ -101,6 +102,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
if request.method == "POST":
try:
update_space(
@@ -137,6 +140,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
try:
archive_space(member, space)
messages.success(request, f"Space “{space.title}” archived.")
@@ -167,6 +172,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
"space": space,
"page": rendered,
"pages": pages,
+ "can_write": can_write_space(member, space),
"forgejo_repo_url": forgejo_repo_url(space),
"forgejo_edit_url": forgejo_edit_url(space, page),
"forgejo_blob_url": forgejo_blob_url(space, page),
@@ -184,6 +190,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
if page_slug != "index":
try:
@@ -271,6 +279,8 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
title = ""
abstractor = ""
@@ -333,6 +343,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
try:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found")
@@ -354,13 +366,17 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
- all_spaces = list(spaces_for_member(member))
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
+ all_spaces = [s for s in spaces_for_member(member) if can_write_space(member, s)]
if request.method == "POST":
dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip()
dest_space_slug = (request.POST.get("dest_space") or space.slug).strip()
dest_space = get_space_for_member(member, dest_space_slug)
title = (request.POST.get("title") or "").strip() or None
try:
+ if dest_space is None:
+ raise pageops.PageOpsError("Choose a destination space")
doc = copy_space_page(
member,
space,
@@ -400,7 +416,10 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
- all_spaces = [s for s in spaces_for_member(member) if s.slug != space.slug]
+ if not can_write_space(member, space):
+ return HttpResponseForbidden("You have read-only access to this space.")
+ all_spaces = [s for s in spaces_for_member(member)
+ if s.slug != space.slug and can_write_space(member, s)]
if request.method == "POST":
dest_space_slug = (request.POST.get("dest_space") or "").strip()
dest_slug = (request.POST.get("dest_slug") or page_slug).strip()
diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html
index c2b1492..0013571 100644
--- a/coulomb_social/templates/spaces/detail.html
+++ b/coulomb_social/templates/spaces/detail.html
@@ -90,13 +90,17 @@
+ {% if can_write %}
Edit page
{% if page.slug and page.slug != 'index' %}
Copy
@@ -112,6 +116,7 @@
{% if forgejo_edit_url %}
Edit in Forgejo
{% endif %}
+ {% endif %}
{% if forgejo_blob_url %}
View source
{% endif %}
diff --git a/docs/deploy.md b/docs/deploy.md
index 5c0dd5e..69d6710 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -132,6 +132,26 @@ kubectl -n coulomb-social exec deploy/coulomb-social -- \
# optional: --slug demo --title "Demo space" --tenant tenant:coulomb
```
+Before the browser smoke, grant the nominated member access to the seeded
+space. Seed/admin/import rows do not become tenant-public automatically. Use the
+reviewed deployment administration path, or Django admin, to create an explicit
+SpaceMembership for the correct tenant and issuer+subject. Existing spaces
+without grants/creator links will be hidden by the new access rules.
+
+Example ORM operation (substitute the **reviewed** non-secret identity):
+
+```python
+from coulomb_social.apps.members.models import Member
+from coulomb_social.apps.spaces.models import Space, SpaceMembership
+member = Member.objects.get(
+ tenant_id="tenant:coulomb", issuer="https://your-issuer", subject="known-subject"
+)
+space = Space.objects.get(tenant_id=member.tenant_id, slug="demo")
+SpaceMembership.objects.update_or_create(
+ space=space, member=member, defaults={"role": SpaceMembership.Role.OWNER}
+)
+```
+
Browser:
1. https://app.coulomb.social/ → **Sign in** (tegwick)
@@ -181,7 +201,7 @@ Space.objects.update_or_create(
4. If the repo is private, set `FORGEJO_TOKEN` (read-only deploy token) via
`railiance-apps` env-secret tooling — **never** commit the token.
5. Open `https://app.coulomb.social/app/spaces/my-space/` as a signed-in member
- of that tenant.
+ with an explicit membership or creator grant.
### D. Optional push webhook (auto cache bust)
diff --git a/docs/migration/corpus-import.md b/docs/migration/corpus-import.md
index dcbff1c..78a27dd 100644
--- a/docs/migration/corpus-import.md
+++ b/docs/migration/corpus-import.md
@@ -27,11 +27,11 @@ matched by **tenant + issuer + subject**, never email or a password.
}
```
-Roles: `owner`, `member`, `viewer`. Mapping preserves these labels; current
-product authorization does not yet enforce viewer write denial. Do not rely on
-role labels as a privacy guarantee. Each Space gets an owner and explicit grants,
-which disables the tenant-wide bootstrap visibility fallback for that tenant.
-Review the effect on existing spaces before executing a real cohort migration.
+Roles: `owner`, `member`, `viewer`. Product authorization denies writes for viewer grants and permits
+owner/member writes; reads require an explicit membership or creator link.
+Tenant-wide bootstrap visibility has been removed. The manifest supplies owner
+and explicit grants, but does not certify that they match original Bubble
+privacy. Review the effect on existing spaces before executing a real cohort migration.
## Run
@@ -69,8 +69,9 @@ tenant boundaries intact: filesystem paths remain slug-keyed.
The single-tree command shares validation and staging. `--member-subject` must
resolve to exactly one tenant-local Member; use the corpus manifest when issuer
-ambiguity is possible. Single-tree operator imports may still omit membership,
-so they are not a replacement for approved cohort mapping.
+ambiguity is possible. Single-tree imports may still omit membership,
+in which case the space is hidden unless it has an existing membership/creator
+grant. They are not a replacement for approved cohort mapping.
## Paired backup/restore protocol (acceptance still pending)
diff --git a/docs/session-pickup.md b/docs/session-pickup.md
index 40ae5fc..2be59c4 100644
--- a/docs/session-pickup.md
+++ b/docs/session-pickup.md
@@ -1,3 +1,19 @@
+# Current implementation focus — 2026-10-06
+
+CSOC-WP-0008 now targets the first **user-reviewed use-case transfer off Bubble**.
+Milestone, sequencing and open tasks are in that workplan; first catalogue and
+walkthrough are docs/usecases/catalog.md and first-transfer-review.md. Original
+journeys remain inferred until observed/confirmed; first real cohort pending.
+
+Implementation started with explicit membership/creator reads, reader mutation
+denial, correct destination write permissions and read-only UI. Unbound seeded
+spaces are now hidden; grant nominated members explicitly before deployment.
+86 tests pass; no live release or cohort move was executed. Next: sharing UX and
+member portability (T04), content/Visual fidelity (T05), paired recovery (T02),
+then exact release preparation (T01) and cohort review (T03/T08/T09).
+
+---
+
# Current pickup — 2026-10-06
WP-0007 audits scope and implements validated corpus import with explicit
diff --git a/docs/usecases/catalog.md b/docs/usecases/catalog.md
new file mode 100644
index 0000000..2aacae0
--- /dev/null
+++ b/docs/usecases/catalog.md
@@ -0,0 +1,71 @@
+# Use-case catalogue — Bubble off-ramp
+
+This is the working product backlog for finding, describing, rebuilding and
+transferring use cases. A capability name or exported type does not prove an
+actual current user workflow. Candidate journeys below derive from the dated
+Bubble inventory/capability map; no fresh authenticated Bubble observation has
+been performed in this session. All transfer decisions are **pending user review**.
+
+## Transfer workflow
+
+For every journey: capture a real example and original steps → describe actor,
+goal, inputs/results and privacy → implement equivalent behavior → rehearse
+representative data → compare old/new from the user's perspective → record the
+user's acceptance or gaps → move that use case and monitor it. Record follow-up
+gaps in live work records before claiming completion. Bubble remains available
+for journeys not yet accepted; full off-ramp follows their disposition.
+
+Evidence labels: `inferred` (inventory/package signals), `observed` (dated actual
+workflow), `confirmed` (user validates goal/steps). Implementation and acceptance
+are separate: tests can prove new behavior, not the original workflow or user
+acceptance. Do not mark transfer accepted on the strength of tests alone.
+
+## First review cohort — candidate journeys
+
+| ID | Actor and user goal | Bubble evidence / confidence | Rebuild and remaining gaps | Owner / transfer |
+|---|---|---|---|---|
+| CSOC-UC-001 | Returning/new member signs in and reaches their work | Login/signup strings and historical identity evidence; original transfer journey inferred | OIDC + registration entry implemented; current cohort onboarding and live Case B need review | WP-0008-T01/T03; pending |
+| CSOC-UC-002 | Writer finds an existing space and opens a page to continue work | `vw_spaces`, `vw_pages`, space/page fields; inferred; one historical import rehearsal | Space/page navigation and markdown implemented; nominated real content, visuals, missing hierarchy and recognizability need review | T03/T05; pending |
+| CSOC-UC-003 | Writer creates and maintains a space/page with Title, Abstractor, Visual and body | Space/page create/edit symbols and captured card UI; inferred | Metadata/body forms implemented; Visual serving, safe content versioning and restart persistence outstanding | T01/T04/T05; pending |
+| CSOC-UC-004 | Writer makes an independent copy, or moves a page to another workspace | Copy icons captured; exact Bubble transfer action unresolved | Copy/move implemented; arbitrary assets/links and actual original semantics need comparison | T04/T05; pending |
+| CSOC-UC-005 | Reader sees shared work; outsider cannot access it; writer remains able to edit | Members/Guests/IsPrivate fields; original privacy rules inferred | First slice enforces viewer write denial and explicit membership/creator reads; membership management and exact Bubble privacy still outstanding | T04; pending |
+| CSOC-UC-006 | Member can take their app-local information with them or leave with a clear content outcome | Platform-contract portability need; Bubble equivalent **unknown** | Export/delete not implemented; ownership/retention UX requires definition | T04; pending |
+| CSOC-UC-007 | Member returns after a restart/incident and their work is still there | Continuity requirement, not a captured Bubble action | Persistent volume and paired DB/content restore evidence outstanding | T01/T02; pending |
+
+### Concrete scenario specification for the first review
+
+Use a user-nominated existing space, a representative page with actual Visual and
+body-linked assets, a second destination space, a writer, a reader and an
+ungranted same-tenant member. Keep all private names/content/account mappings
+outside git. Confirm the original paths and whether “page” means a page, chunk
+or article before import. Until nominated, synthetic examples prove mechanics
+only and cannot be labelled the real transfer cohort.
+
+For UC-002/003: open the original page, note navigation and what is visible;
+open its migrated equivalent, compare Title/Abstractor/Visual/body and essential
+links; edit it, reopen it, and verify the change survives restart.
+For UC-004: confirm original copy/move semantics; copy into the second space,
+edit the copy and confirm the source is independent; move another page and
+verify source removal, destination content and access.
+For UC-005: reader can open the shared page, sees read-only access and cannot
+change it by direct requests; outsider gets no content; destination write
+permission is checked before copy/move. Exact sharing UI remains part of T04.
+For UC-006: compare the app-local export and proposed leaving behavior before
+confirming deletion; platform account handling is explicitly separate.
+For UC-007: compare the pre-incident content and access with an isolated restore.
+
+## Later candidates to discover, not silently discard
+
+| Candidate | Evidence and unresolved user question |
+|---|---|
+| Research hierarchy / chunks / cards | `vw_pages`, `vw_chunks`, parent/chunk/card signals. Which actual workflows require these rather than page bodies? |
+| Find work by search / featured spaces / feed | `vw_search`, `vw_feed`, featured labels. Which discovery tasks are actively used? |
+| Invite people and manage membership | Invite/member/guest signals. Who shares with whom, with which permissions and confirmation? |
+| Follow/favorite/interact | Likes/follow/social type signals. Which workflows are used rather than abandoned UI? |
+| Work tracking / ventures / challenges | Tasklet/venture/challenge types. Capture a real complete journey before choosing its rebuild form. |
+| Marketplace and payments | Marketing and checkout symbols. Confirm whether there is any active end-to-end use before prioritizing. |
+
+Historical source: `docs/capability/existing-bubble-capability-map.md`,
+`docs/bubble-inventory/2026-08-12-surface-inventory.md`, migration rehearsal and
+accepted stage-1 cut. Unknowns remain open; this catalogue does not change that
+cut or claim full parity.
diff --git a/docs/usecases/first-transfer-review.md b/docs/usecases/first-transfer-review.md
new file mode 100644
index 0000000..21756b5
--- /dev/null
+++ b/docs/usecases/first-transfer-review.md
@@ -0,0 +1,48 @@
+# First use-case transfer — user review
+
+**Milestone:** I can continue a nominated piece of my existing work on the new
+app, recognize the content, organize it, share it with the intended people and
+trust it to remain available. I can see exactly which old use cases can move
+and which still need rebuilding.
+
+**Status:** walkthrough prepared; user review not yet ready. WP-0008 is active.
+Local permission/import tests do not establish a deployed, migrated review
+instance. This document will carry the release/evidence links before review.
+
+## Review preparation (owned by WP-0008)
+
+- Review host and immutable release: pending T01.
+- Nominated source workspace/pages and private member mapping: pending T03.
+- Original Bubble walkthrough and confidence confirmation: pending T08/user.
+- Migrated content/visual/link reconciliation and accepted differences: pending T03/T05.
+- Writer, reader and outsider access; sharing and portability UX: pending T04.
+- Restart durability and measured isolated restore: pending T01/T02.
+
+Use a private companion record for real content and account identities. Record
+only non-secret receipts, stable journey IDs and user verdicts here.
+
+## Walkthrough and expected user results
+
+| Journey | What you do | What you should experience | Review verdict |
+|---|---|---|---|
+| UC-001 | Sign in (or register) and find the nominated space | Reach the right work without a Bubble login dependency | Pending |
+| UC-002 | Open the same representative page in Bubble and the new app | Recognizable title, abstract, visual, body and useful navigation; differences clearly disclosed | Pending |
+| UC-003 | Edit a page, make a new page and update space details; revisit after restart | Changes remain readable and usable without manual Git/database actions | Pending |
+| UC-004 | Copy into a second space, edit the copy, then move another page | Copy is independent; moved page leaves source and appears intact at destination; permissions remain correct | Pending |
+| UC-005 | Repeat as reader, then outsider; try editing/copy/move URLs directly | Reader sees shared content and read-only controls; changes denied; outsider sees no private content | Pending |
+| UC-006 | Export app-local account information; review leaving/ownership outcome | Understand what can be taken away and what deletion retains/transfers before confirming it | Pending |
+| UC-007 | Inspect the restored isolated workspace and permission checks | Content and access match the agreed recovery point; recovery delay is known | Pending |
+
+For each row record: original steps observed, new steps performed, release/date,
+result (`pass`, `gap`, `not tested`), gap work-record ID, user verdict
+(`accepted for transfer`, `needs rebuilding`, `deferred`) and explicit acceptance
+reference. Prepared/implemented/tested never automatically means accepted.
+
+## After your review
+
+Move only accepted use cases and their agreed content/cohort. Decide where new
+work happens for each accepted journey; keep the Bubble equivalent available
+according to the agreed rollback/reference policy. File rejected differences as
+live work, select the next user-prioritized cohort and repeat. Retiring Bubble
+entirely is a separate final decision after every retained use case has been
+transferred or explicitly discontinued by you.
diff --git a/tests/test_space_permissions.py b/tests/test_space_permissions.py
new file mode 100644
index 0000000..7f88ea8
--- /dev/null
+++ b/tests/test_space_permissions.py
@@ -0,0 +1,130 @@
+"""User journey UC-005: reader visibility with mutation denial."""
+
+import pytest
+from django.urls import reverse
+
+from coulomb_social.apps.members.models import Member, User
+from coulomb_social.apps.spaces import pageops
+from coulomb_social.apps.spaces.models import Space, SpaceMembership
+from coulomb_social.apps.spaces.services import (
+ can_write_space,
+ copy_space_page,
+ create_space,
+ put_space_page,
+ transfer_space_page,
+)
+
+
+def make_member(subject, tenant="tenant:a"):
+ return Member.objects.create(
+ tenant_id=tenant, user=User.objects.create_user(username=subject),
+ user_engine_user_id=subject, issuer="https://issuer.test", subject=subject,
+ )
+
+
+@pytest.fixture
+def shared(tmp_path, settings, client):
+ settings.CONTENT_ROOT = str(tmp_path / "content")
+ owner = make_member("owner")
+ reader = make_member("reader")
+ outsider = make_member("outsider")
+ space = create_space(owner, title="Shared", slug="shared")
+ put_space_page(owner, space, "notes", title="Notes", body="Original work")
+ SpaceMembership.objects.create(space=space, member=reader, role="viewer")
+ client.force_login(reader.user)
+ return owner, reader, outsider, space
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("route", ["edit", "archive", "page_new", "page_edit",
+ "page_delete", "page_copy", "page_transfer"])
+def test_reader_cannot_mutate_by_direct_request(shared, client, route):
+ _, _, _, space = shared
+ kwargs = {"slug": space.slug}
+ if route in {"page_edit", "page_delete", "page_copy", "page_transfer"}:
+ kwargs["page_slug"] = "notes"
+ response = client.post(reverse("spaces:" + route, kwargs=kwargs),
+ {"title": "Changed", "body": "Tampered",
+ "dest_space": "shared", "dest_slug": "copy"})
+ assert response.status_code == 403
+ assert pageops.read_page("shared", "notes").body == "Original work\n"
+ assert not pageops.read_page("shared", "copy").exists
+ space.refresh_from_db()
+ assert space.title == "Shared"
+ assert space.is_active
+
+
+@pytest.mark.django_db
+def test_reader_sees_read_only_controls_outsider_sees_no_content(shared, client):
+ _, _, outsider, space = shared
+ url = reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=notes"
+ response = client.get(url)
+ assert response.status_code == 200
+ assert b"Original work" in response.content
+ assert b"Read-only access" in response.content
+ assert b"Edit page" not in response.content
+ assert b"New page" not in response.content
+ client.force_login(outsider.user)
+ response = client.get(url)
+ assert response.status_code == 404
+ assert b"Original work" not in response.content
+
+
+@pytest.mark.django_db
+def test_member_can_edit_but_viewer_cannot_use_services(shared):
+ _, reader, _, space = shared
+ with pytest.raises(pageops.PageOpsError, match="read-only"):
+ put_space_page(reader, space, "notes", title="Changed")
+ membership = SpaceMembership.objects.get(space=space, member=reader)
+ membership.role = "member"
+ membership.save()
+ put_space_page(reader, space, "notes", title="Changed", body="Allowed")
+ assert pageops.read_page("shared", "notes").body == "Allowed\n"
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("operation", ["copy", "move"])
+def test_destination_read_only_denies_transfer_without_source_change(shared, operation):
+ owner, _, _, source = shared
+ other = make_member("destination-owner")
+ destination = create_space(other, title="Destination", slug="destination")
+ SpaceMembership.objects.create(space=destination, member=owner, role="viewer")
+ with pytest.raises(pageops.PageOpsError, match="read-only"):
+ if operation == "copy":
+ copy_space_page(owner, source, "notes", dest_space=destination, dest_slug="copy")
+ else:
+ transfer_space_page(owner, source, "notes", destination)
+ assert pageops.read_page("shared", "notes").exists
+ assert not pageops.read_page("destination", "notes").exists
+ assert not pageops.read_page("destination", "copy").exists
+
+
+@pytest.mark.django_db
+def test_same_slug_other_tenant_cannot_be_written(shared):
+ owner, _, _, _ = shared
+ foreign = Space.objects.create(tenant_id="tenant:b", slug="shared", title="Foreign",
+ created_by=owner)
+ assert not can_write_space(owner, foreign)
+ with pytest.raises(pageops.PageOpsError, match="read-only"):
+ put_space_page(owner, foreign, "notes", title="Changed")
+ assert pageops.read_page("shared", "notes").body == "Original work\n"
+
+
+@pytest.mark.django_db
+def test_space_without_grants_is_not_tenant_public(client):
+ member = make_member("ungranted")
+ space = Space.objects.create(tenant_id=member.tenant_id, slug="private", title="Private")
+ client.force_login(member.user)
+ assert client.get(reverse("spaces:detail", kwargs={"slug": space.slug})).status_code == 404
+ assert b"Private" not in client.get(reverse("core:app_home")).content
+
+
+@pytest.mark.django_db
+def test_explicit_reader_role_overrides_creator_write_grant(shared):
+ owner, _, _, space = shared
+ membership = SpaceMembership.objects.get(space=space, member=owner)
+ membership.role = "viewer"
+ membership.save()
+ assert not can_write_space(owner, space)
+ with pytest.raises(pageops.PageOpsError, match="read-only"):
+ put_space_page(owner, space, "notes", title="Should not change")
diff --git a/workplans/CSOC-WP-0008-production-transfer-acceptance.md b/workplans/CSOC-WP-0008-production-transfer-acceptance.md
index 107fa7b..147c08a 100644
--- a/workplans/CSOC-WP-0008-production-transfer-acceptance.md
+++ b/workplans/CSOC-WP-0008-production-transfer-acceptance.md
@@ -1,7 +1,7 @@
---
id: CSOC-WP-0008
type: workplan
-title: "Production persistence, recovery and community transfer acceptance"
+title: "First user-reviewed use-case transfer off Bubble"
domain: communication
repo: coulomb-social
status: active
@@ -16,7 +16,7 @@ depends_on:
state_hub_workstream_id: "0ee96868-875c-536e-a661-07198c07a8ce"
---
-# CSOC-WP-0008 — Production transfer acceptance
+# CSOC-WP-0008 — First user-reviewed use-case transfer
Success is a recoverable, accepted community transfer under the existing stage-1
cut. Local migration tooling is implemented by WP-0007; that is not live parity
@@ -24,6 +24,44 @@ or a production readiness claim. Existing CSOC-IN-0003 and CSOC-IN-0004 remain
open until the corresponding acceptance below is evidenced. This record does
not assign or authorize changes in platform-owner repositories.
+## User milestone — Ready to review the first use-case transfer
+
+At completion, you can sign into the new app, open a nominated migrated space,
+recognize its pages and visuals, continue writing and organizing that work,
+copy or move a page, and share it with a reader who cannot alter it. Your changes
+survive an application restart, and the team can recover the content and access
+from a tested backup. You can inspect/export your app-local information and
+review how leaving the app handles owned content before any deletion.
+
+A side-by-side walkthrough describes the original Bubble steps, equivalent new
+steps, expected results and remaining differences. You review this as a user and
+accept or reject **each use case** for productive transfer. Completion means the
+first nominated cohort is ready for this review; it does not pre-approve all
+use cases or retire Bubble. Apex retirement remains an explicit later decision.
+
+The main goal is now a continuing off-ramp: find and describe actual Bubble use
+cases, rebuild them within our ecosystem, compare behavior and transfer approved
+use cases incrementally. The historical feature inventory supplies candidate
+journeys, not proof that each is used or works in the current Bubble product.
+Marketplace/social features stay candidates for subsequent cohorts until actual
+use and user priority justify their implementation.
+
+## Execution and review order
+
+1. Capture candidate journeys from existing evidence; confirm real examples with
+ the user during review. Keep evidence confidence and unanswered questions.
+2. Build and test the missing sharing/access, content, portability and recovery
+ behavior required for the nominated cohort (T04/T05/T02/T07).
+3. Prepare a concrete release and representative content rehearsal; obtain the
+ exact live change authorization required by the estate before deploying (T01).
+4. Reconcile the first nominated corpus/cohort and demonstrate end-to-end user
+ journeys and recovery (T03/T08/T09).
+5. User accepts individual journeys or records gaps as follow-up work. Transfer
+ those accepted use cases; keep Bubble available for remaining cases (T06).
+
+The earlier 71-space/109-article capture remains an inventory to reconcile, not
+an instruction to bulk-move every space before the first user review.
+
## T01 — Prepare persistence and deployment acceptance
```task
@@ -69,12 +107,14 @@ priority: high
state_hub_task_id: "0615eff2-bf2b-503b-8f15-0591d4666446"
```
-Dependencies: founder-approved corpus/cohort, reviewed identity membership map,
+Dependencies: founder-nominated first-use-case corpus/cohort, reviewed identity membership map,
T01/T02 acceptance, local access to private prepared trees. Use the new corpus
manifest and dry-run; resolve every unmapped/inactive identity by NetKingdom
onboarding rather than password/email matching. Rehearse separately, reconcile
all 71 known spaces and 109 captured articles against actual expected inventory,
-report omitted/thin trees, missing assets and parent/privacy lossiness. Approve
+report omitted/thin trees, missing assets and parent/privacy lossiness.
+Execute the first nominated representative cohort; disposition other spaces
+explicitly as subsequent transfers rather than silently excluding them. Approve
role assignments and additional pre-existing grants before real import; content
replacement is additive for membership and does not revoke old grants.
Close CSOC-IN-0003 only after real cohort receipts and exception disposition.
@@ -83,7 +123,7 @@ Close CSOC-IN-0003 only after real cohort receipts and exception disposition.
```task
id: CSOC-WP-0008-T04
-status: todo
+status: progress
priority: high
state_hub_task_id: "eb4da28e-ea04-52fa-92d6-f78296348b83"
```
@@ -143,3 +183,55 @@ Resolve the pre-existing Ruff findings recorded by the WP-0007 assessment,
without changing generated schema semantics or weakening checks. Full lint and
tests must pass; prioritize meaningful unused-fixture/test cleanup over silencing
rules. This task is quality debt rather than a claim of broken runtime behavior.
+
+## T08 — Describe Bubble use cases and rebuild acceptance
+
+```task
+id: CSOC-WP-0008-T08
+status: progress
+priority: high
+state_hub_task_id: "fca49f28-a959-51e2-ad24-5fd73b1e5316"
+```
+
+Create docs/usecases/catalog.md with stable journey IDs, actors, user goals,
+original/rebuild steps, expected results, evidence confidence, gaps, owner tasks
+and transfer status. Start from captured Bubble inventory; mark inferred steps
+and uncertain semantics. User confirmation is required before calling a journey
+observed/accepted. Prioritize working in spaces/pages and sharing access; retain
+research/chunks, discovery, social and marketplace candidates for later cohorts.
+
+## T09 — User review walkthrough and per-use-case off-ramp receipts
+
+```task
+id: CSOC-WP-0008-T09
+status: progress
+priority: high
+state_hub_task_id: "648acce6-21cd-540f-b3b0-c2fcb8eacc60"
+```
+
+Write docs/usecases/first-transfer-review.md with a realistic example space and
+writer, reader and outsider checks; original/new steps; expected visible results;
+restart and restore checks; pass/gap/accepted-for-transfer fields. Record actual
+release/host, corpus reconciliation and recovery receipts before asking the user
+to execute review. Local tests do not mark this milestone done. Capture the
+user's acceptance per journey and remaining gaps as live work records, then
+prepare the next cohort. The user's review is the transfer gate, not a proxy for
+blanket Bubble retirement.
+
+## Implementation started — 2026-10-06
+
+T04 first slice implemented: explicit membership/creator visibility; removed
+tenant-wide bootstrap reads; enforced viewer read-only access in product routes
+and service writes, exact tenant/space checks, and source/destination write
+checks for copy/move. Read-only controls reflect those permissions. Seed/admin
+spaces now need explicit grants; deploy/import docs reflect this behavior.
+
+T08/T09 initial catalogue and review walkthrough prepared under docs/usecases/.
+Original Bubble journeys remain inferred pending observation/user confirmation;
+real cohort and release evidence remain pending. T04 continues for sharing UI,
+member export/delete, tenant filesystem invariant and original privacy parity.
+The milestone is not reached by this local implementation slice.
+
+Validation: **86 tests passed**; Ruff passes changed service/view/test files;
+`git diff --check` passes for edited files. Existing
+19 repository-wide lint findings remain T07 debt; no production change made.