Start user-reviewed Bubble off-ramp and enforce reader permissions

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680
This commit is contained in:
tegwick 2026-10-06 10:28:36 +02:00
parent a793552a66
commit c2696829c4
13 changed files with 468 additions and 46 deletions

View file

@ -1,6 +1,6 @@
--- ---
repo: coulomb-social repo: coulomb-social
updated: "2026-08-10" updated: "2026-10-06"
status: active 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. 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 ## 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. > Exit bubble.io by duplicating the product surface into an agentic, code-owned stack — not by bolting more low-code onto a stalled base.

View file

@ -36,9 +36,10 @@ filesystem tenant namespace or a database-wide concurrency guarantee.
## Practical limits ## Practical limits
- Space visibility has a tenant-wide bootstrap fallback until any tenant - Space reads now require explicit membership or a creator grant. Viewer
memberships exist. Membership roles are stored but viewer/editor enforcement memberships are read-only across product mutation routes and service writes;
and Bubble privacy parity are incomplete. Imported mappings add/update grants; 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. replacement does not revoke old grants. Review access before a real cohort import.
- Copy/transfer handles relative Visual files, not arbitrary body-linked assets, - Copy/transfer handles relative Visual files, not arbitrary body-linked assets,
incoming page links, graph identity or Bubble parent/chunk semantics. Visual incoming page links, graph identity or Bubble parent/chunk semantics. Visual

View file

@ -55,10 +55,12 @@
| task | CSOC-WP-0008-T01 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md | | 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-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-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-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-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-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-0001 | closed | blue | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0002 | closed | green | 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 | | intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md |

View file

@ -12,22 +12,11 @@ from .models import Space, SpaceMembership
def spaces_for_member(member: Member | None) -> QuerySet[Space]: def spaces_for_member(member: Member | None) -> QuerySet[Space]:
"""Spaces visible to this member (same tenant, active). """Only active tenant spaces with an explicit membership or creator link."""
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.
"""
if member is None: if member is None:
return Space.objects.none() return Space.objects.none()
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True) 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 ( return (
qs.filter(Q(memberships__member=member) | Q(created_by=member)) qs.filter(Q(memberships__member=member) | Q(created_by=member))
.distinct() .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() 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 @transaction.atomic
def create_space( def create_space(
member: Member, member: Member,
@ -90,8 +94,7 @@ def update_space(
abstractor: str | None = None, abstractor: str | None = None,
visual: str | None = None, visual: str | None = None,
) -> Space: ) -> Space:
if get_space_for_member(member, space.slug) is None: require_space_write(member, space)
raise pageops.PageOpsError("Space not found")
if title is not None: if title is not None:
space.title = title.strip() or space.title space.title = title.strip() or space.title
if abstractor is not None: if abstractor is not None:
@ -116,8 +119,7 @@ def update_space(
@transaction.atomic @transaction.atomic
def archive_space(member: Member, space: Space) -> Space: def archive_space(member: Member, space: Space) -> Space:
if get_space_for_member(member, space.slug) is None: require_space_write(member, space)
raise pageops.PageOpsError("Space not found")
space.is_active = False space.is_active = False
space.save(update_fields=["is_active", "updated_at"]) space.save(update_fields=["is_active", "updated_at"])
try: try:
@ -142,8 +144,7 @@ def put_space_page(
visual: str = "", visual: str = "",
body: str = "", body: str = "",
) -> pageops.PageDoc: ) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None: require_space_write(member, space)
raise pageops.PageOpsError("Space not found")
return pageops.write_page( return pageops.write_page(
space.slug, space.slug,
page_slug, page_slug,
@ -163,11 +164,9 @@ def copy_space_page(
dest_space: Space | None = None, dest_space: Space | None = None,
title: str | None = None, title: str | None = None,
) -> pageops.PageDoc: ) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None: require_space_write(member, space)
raise pageops.PageOpsError("Source space not found")
target = dest_space or space target = dest_space or space
if get_space_for_member(member, target.slug) is None: require_space_write(member, target)
raise pageops.PageOpsError("Destination space not found")
return pageops.copy_page( return pageops.copy_page(
space.slug, space.slug,
page_slug, page_slug,
@ -185,10 +184,8 @@ def transfer_space_page(
*, *,
dest_slug: str | None = None, dest_slug: str | None = None,
) -> pageops.PageDoc: ) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None: require_space_write(member, space)
raise pageops.PageOpsError("Source space not found") require_space_write(member, dest_space)
if get_space_for_member(member, dest_space.slug) is None:
raise pageops.PageOpsError("Destination space not found")
return pageops.transfer_page( return pageops.transfer_page(
space.slug, space.slug,
page_slug, page_slug,

View file

@ -32,6 +32,7 @@ from .content import (
from .models import Space from .models import Space
from .services import ( from .services import (
archive_space, archive_space,
can_write_space,
copy_space_page, copy_space_page,
create_space, create_space,
get_space_for_member, get_space_for_member,
@ -101,6 +102,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") 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": if request.method == "POST":
try: try:
update_space( update_space(
@ -137,6 +140,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") return HttpResponseNotFound("Space not found.")
if not can_write_space(member, space):
return HttpResponseForbidden("You have read-only access to this space.")
try: try:
archive_space(member, space) archive_space(member, space)
messages.success(request, f"Space “{space.title}” archived.") messages.success(request, f"Space “{space.title}” archived.")
@ -167,6 +172,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
"space": space, "space": space,
"page": rendered, "page": rendered,
"pages": pages, "pages": pages,
"can_write": can_write_space(member, space),
"forgejo_repo_url": forgejo_repo_url(space), "forgejo_repo_url": forgejo_repo_url(space),
"forgejo_edit_url": forgejo_edit_url(space, page), "forgejo_edit_url": forgejo_edit_url(space, page),
"forgejo_blob_url": forgejo_blob_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) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") 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": if page_slug != "index":
try: try:
@ -271,6 +279,8 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse:
space = get_space_for_member(member, slug) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") return HttpResponseNotFound("Space not found.")
if not can_write_space(member, space):
return HttpResponseForbidden("You have read-only access to this space.")
title = "" title = ""
abstractor = "" abstractor = ""
@ -333,6 +343,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse
space = get_space_for_member(member, slug) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") return HttpResponseNotFound("Space not found.")
if not can_write_space(member, space):
return HttpResponseForbidden("You have read-only access to this space.")
try: try:
if get_space_for_member(member, space.slug) is None: if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found") 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) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") 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": if request.method == "POST":
dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip() 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_slug = (request.POST.get("dest_space") or space.slug).strip()
dest_space = get_space_for_member(member, dest_space_slug) dest_space = get_space_for_member(member, dest_space_slug)
title = (request.POST.get("title") or "").strip() or None title = (request.POST.get("title") or "").strip() or None
try: try:
if dest_space is None:
raise pageops.PageOpsError("Choose a destination space")
doc = copy_space_page( doc = copy_space_page(
member, member,
space, space,
@ -400,7 +416,10 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon
space = get_space_for_member(member, slug) space = get_space_for_member(member, slug)
if space is None: if space is None:
return HttpResponseNotFound("Space not found.") 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": if request.method == "POST":
dest_space_slug = (request.POST.get("dest_space") or "").strip() dest_space_slug = (request.POST.get("dest_space") or "").strip()
dest_slug = (request.POST.get("dest_slug") or page_slug).strip() dest_slug = (request.POST.get("dest_slug") or page_slug).strip()

View file

@ -90,13 +90,17 @@
<p class="content-meta muted" style="margin:0.35rem 0 0;">Visual: {{ space.visual }}</p> <p class="content-meta muted" style="margin:0.35rem 0 0;">Visual: {{ space.visual }}</p>
{% endif %} {% endif %}
</div> </div>
{% if can_write %}
<div class="content-actions" style="margin:0;"> <div class="content-actions" style="margin:0;">
<a class="btn secondary small" href="{% url 'spaces:edit' space.slug %}">Edit space</a> <a class="btn secondary small" href="{% url 'spaces:edit' space.slug %}">Edit space</a>
<a class="btn secondary small" href="{% url 'spaces:page_new' space.slug %}">New page</a> <a class="btn secondary small" href="{% url 'spaces:page_new' space.slug %}">New page</a>
</div> </div>
{% endif %}
</div> </div>
{% if not can_write %}<p class="muted">Read-only access</p>{% endif %}
<div class="content-actions"> <div class="content-actions">
{% if can_write %}
<a class="btn small" href="{% url 'spaces:page_edit' space.slug page.slug|default:'index' %}">Edit page</a> <a class="btn small" href="{% url 'spaces:page_edit' space.slug page.slug|default:'index' %}">Edit page</a>
{% if page.slug and page.slug != 'index' %} {% if page.slug and page.slug != 'index' %}
<a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug page.slug %}">Copy</a> <a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug page.slug %}">Copy</a>
@ -112,6 +116,7 @@
{% if forgejo_edit_url %} {% if forgejo_edit_url %}
<a class="btn secondary small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a> <a class="btn secondary small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
{% endif %} {% endif %}
{% endif %}
{% if forgejo_blob_url %} {% if forgejo_blob_url %}
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a> <a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
{% endif %} {% endif %}

View file

@ -132,6 +132,26 @@ kubectl -n coulomb-social exec deploy/coulomb-social -- \
# optional: --slug demo --title "Demo space" --tenant tenant:coulomb # 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: Browser:
1. https://app.coulomb.social/ → **Sign in** (tegwick) 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 4. If the repo is private, set `FORGEJO_TOKEN` (read-only deploy token) via
`railiance-apps` env-secret tooling — **never** commit the token. `railiance-apps` env-secret tooling — **never** commit the token.
5. Open `https://app.coulomb.social/app/spaces/my-space/` as a signed-in member 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) ### D. Optional push webhook (auto cache bust)

View file

@ -27,11 +27,11 @@ matched by **tenant + issuer + subject**, never email or a password.
} }
``` ```
Roles: `owner`, `member`, `viewer`. Mapping preserves these labels; current Roles: `owner`, `member`, `viewer`. Product authorization denies writes for viewer grants and permits
product authorization does not yet enforce viewer write denial. Do not rely on owner/member writes; reads require an explicit membership or creator link.
role labels as a privacy guarantee. Each Space gets an owner and explicit grants, Tenant-wide bootstrap visibility has been removed. The manifest supplies owner
which disables the tenant-wide bootstrap visibility fallback for that tenant. and explicit grants, but does not certify that they match original Bubble
Review the effect on existing spaces before executing a real cohort migration. privacy. Review the effect on existing spaces before executing a real cohort migration.
## Run ## Run
@ -69,8 +69,9 @@ tenant boundaries intact: filesystem paths remain slug-keyed.
The single-tree command shares validation and staging. `--member-subject` must The single-tree command shares validation and staging. `--member-subject` must
resolve to exactly one tenant-local Member; use the corpus manifest when issuer resolve to exactly one tenant-local Member; use the corpus manifest when issuer
ambiguity is possible. Single-tree operator imports may still omit membership, ambiguity is possible. Single-tree imports may still omit membership,
so they are not a replacement for approved cohort mapping. 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) ## Paired backup/restore protocol (acceptance still pending)

View file

@ -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 # Current pickup — 2026-10-06
WP-0007 audits scope and implements validated corpus import with explicit WP-0007 audits scope and implements validated corpus import with explicit

71
docs/usecases/catalog.md Normal file
View file

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

View file

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

View file

@ -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")

View file

@ -1,7 +1,7 @@
--- ---
id: CSOC-WP-0008 id: CSOC-WP-0008
type: workplan type: workplan
title: "Production persistence, recovery and community transfer acceptance" title: "First user-reviewed use-case transfer off Bubble"
domain: communication domain: communication
repo: coulomb-social repo: coulomb-social
status: active status: active
@ -16,7 +16,7 @@ depends_on:
state_hub_workstream_id: "0ee96868-875c-536e-a661-07198c07a8ce" 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 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 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 open until the corresponding acceptance below is evidenced. This record does
not assign or authorize changes in platform-owner repositories. 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 ## T01 — Prepare persistence and deployment acceptance
```task ```task
@ -69,12 +107,14 @@ priority: high
state_hub_task_id: "0615eff2-bf2b-503b-8f15-0591d4666446" 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 T01/T02 acceptance, local access to private prepared trees. Use the new corpus
manifest and dry-run; resolve every unmapped/inactive identity by NetKingdom manifest and dry-run; resolve every unmapped/inactive identity by NetKingdom
onboarding rather than password/email matching. Rehearse separately, reconcile onboarding rather than password/email matching. Rehearse separately, reconcile
all 71 known spaces and 109 captured articles against actual expected inventory, 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 role assignments and additional pre-existing grants before real import; content
replacement is additive for membership and does not revoke old grants. replacement is additive for membership and does not revoke old grants.
Close CSOC-IN-0003 only after real cohort receipts and exception disposition. 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 ```task
id: CSOC-WP-0008-T04 id: CSOC-WP-0008-T04
status: todo status: progress
priority: high priority: high
state_hub_task_id: "eb4da28e-ea04-52fa-92d6-f78296348b83" 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 without changing generated schema semantics or weakening checks. Full lint and
tests must pass; prioritize meaningful unused-fixture/test cleanup over silencing 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. 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.