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:
parent
a793552a66
commit
c2696829c4
13 changed files with 468 additions and 46 deletions
22
INTENT.md
22
INTENT.md
|
|
@ -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.
|
||||||
|
|
|
||||||
7
SCOPE.md
7
SCOPE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
71
docs/usecases/catalog.md
Normal 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.
|
||||||
48
docs/usecases/first-transfer-review.md
Normal file
48
docs/usecases/first-transfer-review.md
Normal 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.
|
||||||
130
tests/test_space_permissions.py
Normal file
130
tests/test_space_permissions.py
Normal 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")
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue