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
|
||||
updated: "2026-08-10"
|
||||
updated: "2026-10-06"
|
||||
status: active
|
||||
---
|
||||
|
||||
|
|
@ -27,6 +27,26 @@ This repository is the **owning home** for the rebuild:
|
|||
|
||||
Binky Hedgehog GmbH is **client #1** of its own platform (DR-2 B). The app is not a Core Hub extension; it is a normal client-facing business application.
|
||||
|
||||
## Current focus — User-reviewed use-case off-ramp
|
||||
|
||||
Find and describe the use cases actually used in the current coulomb.social
|
||||
implementation, rebuild them on our ecosystem, and move them off Bubble one
|
||||
reviewed cohort at a time. Start with a nominated spaces/pages workflow under
|
||||
the accepted stage-1 cut; captured feature/type inventories are discovery inputs,
|
||||
not proof of complete current use-case behavior.
|
||||
|
||||
The first milestone is **ready for user review of the first use-case transfer**:
|
||||
the user can recognize migrated work, continue creating/editing/organizing pages,
|
||||
share with the intended readers, and trust persistence and recovery. A comparison
|
||||
walkthrough records old/new steps and gaps; the user accepts or rejects each
|
||||
journey for transfer. Broader off-ramp continues by observing, describing,
|
||||
rebuilding, comparing and transferring subsequent use cases. Full Bubble
|
||||
retirement follows explicit disposition of all retained use cases.
|
||||
|
||||
Execution: `workplans/CSOC-WP-0008-production-transfer-acceptance.md`.
|
||||
Product backlog: `docs/usecases/catalog.md`.
|
||||
User review: `docs/usecases/first-transfer-review.md`.
|
||||
|
||||
## Core Idea
|
||||
|
||||
> Exit bubble.io by duplicating the product surface into an agentic, code-owned stack — not by bolting more low-code onto a stalled base.
|
||||
|
|
|
|||
7
SCOPE.md
7
SCOPE.md
|
|
@ -36,9 +36,10 @@ filesystem tenant namespace or a database-wide concurrency guarantee.
|
|||
|
||||
## Practical limits
|
||||
|
||||
- Space visibility has a tenant-wide bootstrap fallback until any tenant
|
||||
memberships exist. Membership roles are stored but viewer/editor enforcement
|
||||
and Bubble privacy parity are incomplete. Imported mappings add/update grants;
|
||||
- Space reads now require explicit membership or a creator grant. Viewer
|
||||
memberships are read-only across product mutation routes and service writes;
|
||||
owner/member grants permit writes. Sharing-management UI and exact Bubble
|
||||
privacy parity remain incomplete. Imported mappings add/update grants;
|
||||
replacement does not revoke old grants. Review access before a real cohort import.
|
||||
- Copy/transfer handles relative Visual files, not arbitrary body-linked assets,
|
||||
incoming page links, graph identity or Bubble parent/chunk semantics. Visual
|
||||
|
|
|
|||
|
|
@ -55,10 +55,12 @@
|
|||
| task | CSOC-WP-0008-T01 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T02 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T03 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T04 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T04 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T05 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T06 | wait | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T07 | todo | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T08 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| task | CSOC-WP-0008-T09 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
|
||||
| intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md |
|
||||
| intake | CSOC-IN-0002 | closed | green | docs/intakes/csoc-residuals.md |
|
||||
| intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md |
|
||||
|
|
|
|||
|
|
@ -12,22 +12,11 @@ from .models import Space, SpaceMembership
|
|||
|
||||
|
||||
def spaces_for_member(member: Member | None) -> QuerySet[Space]:
|
||||
"""Spaces visible to this member (same tenant, active).
|
||||
|
||||
Explicit SpaceMembership grants access; if none exist for the tenant yet,
|
||||
all active tenant spaces are listed (operator-seeded MVP). Once any
|
||||
memberships exist for the tenant, only membership + created_by apply.
|
||||
"""
|
||||
"""Only active tenant spaces with an explicit membership or creator link."""
|
||||
if member is None:
|
||||
return Space.objects.none()
|
||||
|
||||
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True)
|
||||
tenant_has_memberships = SpaceMembership.objects.filter(
|
||||
space__tenant_id=member.tenant_id
|
||||
).exists()
|
||||
if not tenant_has_memberships:
|
||||
return qs.order_by("title", "slug")
|
||||
|
||||
return (
|
||||
qs.filter(Q(memberships__member=member) | Q(created_by=member))
|
||||
.distinct()
|
||||
|
|
@ -41,6 +30,21 @@ def get_space_for_member(member: Member | None, slug: str) -> Space | None:
|
|||
return spaces_for_member(member).filter(slug=slug).first()
|
||||
|
||||
|
||||
def can_write_space(member: Member | None, space: Space) -> bool:
|
||||
"""Require an exact tenant/space grant, not a same-slug visibility match."""
|
||||
if member is None or member.tenant_id != space.tenant_id or not space.is_active:
|
||||
return False
|
||||
membership = SpaceMembership.objects.filter(space=space, member=member).first()
|
||||
if membership is not None:
|
||||
return membership.role in {SpaceMembership.Role.OWNER, SpaceMembership.Role.MEMBER}
|
||||
return space.created_by_id == member.pk
|
||||
|
||||
|
||||
def require_space_write(member: Member | None, space: Space) -> None:
|
||||
if not can_write_space(member, space):
|
||||
raise pageops.PageOpsError("You have read-only access to this space")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_space(
|
||||
member: Member,
|
||||
|
|
@ -90,8 +94,7 @@ def update_space(
|
|||
abstractor: str | None = None,
|
||||
visual: str | None = None,
|
||||
) -> Space:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Space not found")
|
||||
require_space_write(member, space)
|
||||
if title is not None:
|
||||
space.title = title.strip() or space.title
|
||||
if abstractor is not None:
|
||||
|
|
@ -116,8 +119,7 @@ def update_space(
|
|||
|
||||
@transaction.atomic
|
||||
def archive_space(member: Member, space: Space) -> Space:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Space not found")
|
||||
require_space_write(member, space)
|
||||
space.is_active = False
|
||||
space.save(update_fields=["is_active", "updated_at"])
|
||||
try:
|
||||
|
|
@ -142,8 +144,7 @@ def put_space_page(
|
|||
visual: str = "",
|
||||
body: str = "",
|
||||
) -> pageops.PageDoc:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Space not found")
|
||||
require_space_write(member, space)
|
||||
return pageops.write_page(
|
||||
space.slug,
|
||||
page_slug,
|
||||
|
|
@ -163,11 +164,9 @@ def copy_space_page(
|
|||
dest_space: Space | None = None,
|
||||
title: str | None = None,
|
||||
) -> pageops.PageDoc:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Source space not found")
|
||||
require_space_write(member, space)
|
||||
target = dest_space or space
|
||||
if get_space_for_member(member, target.slug) is None:
|
||||
raise pageops.PageOpsError("Destination space not found")
|
||||
require_space_write(member, target)
|
||||
return pageops.copy_page(
|
||||
space.slug,
|
||||
page_slug,
|
||||
|
|
@ -185,10 +184,8 @@ def transfer_space_page(
|
|||
*,
|
||||
dest_slug: str | None = None,
|
||||
) -> pageops.PageDoc:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Source space not found")
|
||||
if get_space_for_member(member, dest_space.slug) is None:
|
||||
raise pageops.PageOpsError("Destination space not found")
|
||||
require_space_write(member, space)
|
||||
require_space_write(member, dest_space)
|
||||
return pageops.transfer_page(
|
||||
space.slug,
|
||||
page_slug,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from .content import (
|
|||
from .models import Space
|
||||
from .services import (
|
||||
archive_space,
|
||||
can_write_space,
|
||||
copy_space_page,
|
||||
create_space,
|
||||
get_space_for_member,
|
||||
|
|
@ -101,6 +102,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
if request.method == "POST":
|
||||
try:
|
||||
update_space(
|
||||
|
|
@ -137,6 +140,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse:
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
try:
|
||||
archive_space(member, space)
|
||||
messages.success(request, f"Space “{space.title}” archived.")
|
||||
|
|
@ -167,6 +172,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
|
|||
"space": space,
|
||||
"page": rendered,
|
||||
"pages": pages,
|
||||
"can_write": can_write_space(member, space),
|
||||
"forgejo_repo_url": forgejo_repo_url(space),
|
||||
"forgejo_edit_url": forgejo_edit_url(space, page),
|
||||
"forgejo_blob_url": forgejo_blob_url(space, page),
|
||||
|
|
@ -184,6 +190,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
|
||||
if page_slug != "index":
|
||||
try:
|
||||
|
|
@ -271,6 +279,8 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse:
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
|
||||
title = ""
|
||||
abstractor = ""
|
||||
|
|
@ -333,6 +343,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
try:
|
||||
if get_space_for_member(member, space.slug) is None:
|
||||
raise pageops.PageOpsError("Space not found")
|
||||
|
|
@ -354,13 +366,17 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
all_spaces = list(spaces_for_member(member))
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
all_spaces = [s for s in spaces_for_member(member) if can_write_space(member, s)]
|
||||
if request.method == "POST":
|
||||
dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip()
|
||||
dest_space_slug = (request.POST.get("dest_space") or space.slug).strip()
|
||||
dest_space = get_space_for_member(member, dest_space_slug)
|
||||
title = (request.POST.get("title") or "").strip() or None
|
||||
try:
|
||||
if dest_space is None:
|
||||
raise pageops.PageOpsError("Choose a destination space")
|
||||
doc = copy_space_page(
|
||||
member,
|
||||
space,
|
||||
|
|
@ -400,7 +416,10 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon
|
|||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Space not found.")
|
||||
all_spaces = [s for s in spaces_for_member(member) if s.slug != space.slug]
|
||||
if not can_write_space(member, space):
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
all_spaces = [s for s in spaces_for_member(member)
|
||||
if s.slug != space.slug and can_write_space(member, s)]
|
||||
if request.method == "POST":
|
||||
dest_space_slug = (request.POST.get("dest_space") or "").strip()
|
||||
dest_slug = (request.POST.get("dest_slug") or page_slug).strip()
|
||||
|
|
|
|||
|
|
@ -90,13 +90,17 @@
|
|||
<p class="content-meta muted" style="margin:0.35rem 0 0;">Visual: {{ space.visual }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_write %}
|
||||
<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:page_new' space.slug %}">New page</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not can_write %}<p class="muted">Read-only access</p>{% endif %}
|
||||
<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>
|
||||
{% if page.slug and page.slug != 'index' %}
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug page.slug %}">Copy</a>
|
||||
|
|
@ -112,6 +116,7 @@
|
|||
{% if forgejo_edit_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if forgejo_blob_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,26 @@ kubectl -n coulomb-social exec deploy/coulomb-social -- \
|
|||
# optional: --slug demo --title "Demo space" --tenant tenant:coulomb
|
||||
```
|
||||
|
||||
Before the browser smoke, grant the nominated member access to the seeded
|
||||
space. Seed/admin/import rows do not become tenant-public automatically. Use the
|
||||
reviewed deployment administration path, or Django admin, to create an explicit
|
||||
SpaceMembership for the correct tenant and issuer+subject. Existing spaces
|
||||
without grants/creator links will be hidden by the new access rules.
|
||||
|
||||
Example ORM operation (substitute the **reviewed** non-secret identity):
|
||||
|
||||
```python
|
||||
from coulomb_social.apps.members.models import Member
|
||||
from coulomb_social.apps.spaces.models import Space, SpaceMembership
|
||||
member = Member.objects.get(
|
||||
tenant_id="tenant:coulomb", issuer="https://your-issuer", subject="known-subject"
|
||||
)
|
||||
space = Space.objects.get(tenant_id=member.tenant_id, slug="demo")
|
||||
SpaceMembership.objects.update_or_create(
|
||||
space=space, member=member, defaults={"role": SpaceMembership.Role.OWNER}
|
||||
)
|
||||
```
|
||||
|
||||
Browser:
|
||||
|
||||
1. https://app.coulomb.social/ → **Sign in** (tegwick)
|
||||
|
|
@ -181,7 +201,7 @@ Space.objects.update_or_create(
|
|||
4. If the repo is private, set `FORGEJO_TOKEN` (read-only deploy token) via
|
||||
`railiance-apps` env-secret tooling — **never** commit the token.
|
||||
5. Open `https://app.coulomb.social/app/spaces/my-space/` as a signed-in member
|
||||
of that tenant.
|
||||
with an explicit membership or creator grant.
|
||||
|
||||
### D. Optional push webhook (auto cache bust)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ matched by **tenant + issuer + subject**, never email or a password.
|
|||
}
|
||||
```
|
||||
|
||||
Roles: `owner`, `member`, `viewer`. Mapping preserves these labels; current
|
||||
product authorization does not yet enforce viewer write denial. Do not rely on
|
||||
role labels as a privacy guarantee. Each Space gets an owner and explicit grants,
|
||||
which disables the tenant-wide bootstrap visibility fallback for that tenant.
|
||||
Review the effect on existing spaces before executing a real cohort migration.
|
||||
Roles: `owner`, `member`, `viewer`. Product authorization denies writes for viewer grants and permits
|
||||
owner/member writes; reads require an explicit membership or creator link.
|
||||
Tenant-wide bootstrap visibility has been removed. The manifest supplies owner
|
||||
and explicit grants, but does not certify that they match original Bubble
|
||||
privacy. Review the effect on existing spaces before executing a real cohort migration.
|
||||
|
||||
## Run
|
||||
|
||||
|
|
@ -69,8 +69,9 @@ tenant boundaries intact: filesystem paths remain slug-keyed.
|
|||
|
||||
The single-tree command shares validation and staging. `--member-subject` must
|
||||
resolve to exactly one tenant-local Member; use the corpus manifest when issuer
|
||||
ambiguity is possible. Single-tree operator imports may still omit membership,
|
||||
so they are not a replacement for approved cohort mapping.
|
||||
ambiguity is possible. Single-tree imports may still omit membership,
|
||||
in which case the space is hidden unless it has an existing membership/creator
|
||||
grant. They are not a replacement for approved cohort mapping.
|
||||
|
||||
## Paired backup/restore protocol (acceptance still pending)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,19 @@
|
|||
# Current implementation focus — 2026-10-06
|
||||
|
||||
CSOC-WP-0008 now targets the first **user-reviewed use-case transfer off Bubble**.
|
||||
Milestone, sequencing and open tasks are in that workplan; first catalogue and
|
||||
walkthrough are docs/usecases/catalog.md and first-transfer-review.md. Original
|
||||
journeys remain inferred until observed/confirmed; first real cohort pending.
|
||||
|
||||
Implementation started with explicit membership/creator reads, reader mutation
|
||||
denial, correct destination write permissions and read-only UI. Unbound seeded
|
||||
spaces are now hidden; grant nominated members explicitly before deployment.
|
||||
86 tests pass; no live release or cohort move was executed. Next: sharing UX and
|
||||
member portability (T04), content/Visual fidelity (T05), paired recovery (T02),
|
||||
then exact release preparation (T01) and cohort review (T03/T08/T09).
|
||||
|
||||
---
|
||||
|
||||
# Current pickup — 2026-10-06
|
||||
|
||||
WP-0007 audits scope and implements validated corpus import with explicit
|
||||
|
|
|
|||
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
|
||||
type: workplan
|
||||
title: "Production persistence, recovery and community transfer acceptance"
|
||||
title: "First user-reviewed use-case transfer off Bubble"
|
||||
domain: communication
|
||||
repo: coulomb-social
|
||||
status: active
|
||||
|
|
@ -16,7 +16,7 @@ depends_on:
|
|||
state_hub_workstream_id: "0ee96868-875c-536e-a661-07198c07a8ce"
|
||||
---
|
||||
|
||||
# CSOC-WP-0008 — Production transfer acceptance
|
||||
# CSOC-WP-0008 — First user-reviewed use-case transfer
|
||||
|
||||
Success is a recoverable, accepted community transfer under the existing stage-1
|
||||
cut. Local migration tooling is implemented by WP-0007; that is not live parity
|
||||
|
|
@ -24,6 +24,44 @@ or a production readiness claim. Existing CSOC-IN-0003 and CSOC-IN-0004 remain
|
|||
open until the corresponding acceptance below is evidenced. This record does
|
||||
not assign or authorize changes in platform-owner repositories.
|
||||
|
||||
## User milestone — Ready to review the first use-case transfer
|
||||
|
||||
At completion, you can sign into the new app, open a nominated migrated space,
|
||||
recognize its pages and visuals, continue writing and organizing that work,
|
||||
copy or move a page, and share it with a reader who cannot alter it. Your changes
|
||||
survive an application restart, and the team can recover the content and access
|
||||
from a tested backup. You can inspect/export your app-local information and
|
||||
review how leaving the app handles owned content before any deletion.
|
||||
|
||||
A side-by-side walkthrough describes the original Bubble steps, equivalent new
|
||||
steps, expected results and remaining differences. You review this as a user and
|
||||
accept or reject **each use case** for productive transfer. Completion means the
|
||||
first nominated cohort is ready for this review; it does not pre-approve all
|
||||
use cases or retire Bubble. Apex retirement remains an explicit later decision.
|
||||
|
||||
The main goal is now a continuing off-ramp: find and describe actual Bubble use
|
||||
cases, rebuild them within our ecosystem, compare behavior and transfer approved
|
||||
use cases incrementally. The historical feature inventory supplies candidate
|
||||
journeys, not proof that each is used or works in the current Bubble product.
|
||||
Marketplace/social features stay candidates for subsequent cohorts until actual
|
||||
use and user priority justify their implementation.
|
||||
|
||||
## Execution and review order
|
||||
|
||||
1. Capture candidate journeys from existing evidence; confirm real examples with
|
||||
the user during review. Keep evidence confidence and unanswered questions.
|
||||
2. Build and test the missing sharing/access, content, portability and recovery
|
||||
behavior required for the nominated cohort (T04/T05/T02/T07).
|
||||
3. Prepare a concrete release and representative content rehearsal; obtain the
|
||||
exact live change authorization required by the estate before deploying (T01).
|
||||
4. Reconcile the first nominated corpus/cohort and demonstrate end-to-end user
|
||||
journeys and recovery (T03/T08/T09).
|
||||
5. User accepts individual journeys or records gaps as follow-up work. Transfer
|
||||
those accepted use cases; keep Bubble available for remaining cases (T06).
|
||||
|
||||
The earlier 71-space/109-article capture remains an inventory to reconcile, not
|
||||
an instruction to bulk-move every space before the first user review.
|
||||
|
||||
## T01 — Prepare persistence and deployment acceptance
|
||||
|
||||
```task
|
||||
|
|
@ -69,12 +107,14 @@ priority: high
|
|||
state_hub_task_id: "0615eff2-bf2b-503b-8f15-0591d4666446"
|
||||
```
|
||||
|
||||
Dependencies: founder-approved corpus/cohort, reviewed identity membership map,
|
||||
Dependencies: founder-nominated first-use-case corpus/cohort, reviewed identity membership map,
|
||||
T01/T02 acceptance, local access to private prepared trees. Use the new corpus
|
||||
manifest and dry-run; resolve every unmapped/inactive identity by NetKingdom
|
||||
onboarding rather than password/email matching. Rehearse separately, reconcile
|
||||
all 71 known spaces and 109 captured articles against actual expected inventory,
|
||||
report omitted/thin trees, missing assets and parent/privacy lossiness. Approve
|
||||
report omitted/thin trees, missing assets and parent/privacy lossiness.
|
||||
Execute the first nominated representative cohort; disposition other spaces
|
||||
explicitly as subsequent transfers rather than silently excluding them. Approve
|
||||
role assignments and additional pre-existing grants before real import; content
|
||||
replacement is additive for membership and does not revoke old grants.
|
||||
Close CSOC-IN-0003 only after real cohort receipts and exception disposition.
|
||||
|
|
@ -83,7 +123,7 @@ Close CSOC-IN-0003 only after real cohort receipts and exception disposition.
|
|||
|
||||
```task
|
||||
id: CSOC-WP-0008-T04
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "eb4da28e-ea04-52fa-92d6-f78296348b83"
|
||||
```
|
||||
|
|
@ -143,3 +183,55 @@ Resolve the pre-existing Ruff findings recorded by the WP-0007 assessment,
|
|||
without changing generated schema semantics or weakening checks. Full lint and
|
||||
tests must pass; prioritize meaningful unused-fixture/test cleanup over silencing
|
||||
rules. This task is quality debt rather than a claim of broken runtime behavior.
|
||||
|
||||
## T08 — Describe Bubble use cases and rebuild acceptance
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0008-T08
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "fca49f28-a959-51e2-ad24-5fd73b1e5316"
|
||||
```
|
||||
|
||||
Create docs/usecases/catalog.md with stable journey IDs, actors, user goals,
|
||||
original/rebuild steps, expected results, evidence confidence, gaps, owner tasks
|
||||
and transfer status. Start from captured Bubble inventory; mark inferred steps
|
||||
and uncertain semantics. User confirmation is required before calling a journey
|
||||
observed/accepted. Prioritize working in spaces/pages and sharing access; retain
|
||||
research/chunks, discovery, social and marketplace candidates for later cohorts.
|
||||
|
||||
## T09 — User review walkthrough and per-use-case off-ramp receipts
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0008-T09
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "648acce6-21cd-540f-b3b0-c2fcb8eacc60"
|
||||
```
|
||||
|
||||
Write docs/usecases/first-transfer-review.md with a realistic example space and
|
||||
writer, reader and outsider checks; original/new steps; expected visible results;
|
||||
restart and restore checks; pass/gap/accepted-for-transfer fields. Record actual
|
||||
release/host, corpus reconciliation and recovery receipts before asking the user
|
||||
to execute review. Local tests do not mark this milestone done. Capture the
|
||||
user's acceptance per journey and remaining gaps as live work records, then
|
||||
prepare the next cohort. The user's review is the transfer gate, not a proxy for
|
||||
blanket Bubble retirement.
|
||||
|
||||
## Implementation started — 2026-10-06
|
||||
|
||||
T04 first slice implemented: explicit membership/creator visibility; removed
|
||||
tenant-wide bootstrap reads; enforced viewer read-only access in product routes
|
||||
and service writes, exact tenant/space checks, and source/destination write
|
||||
checks for copy/move. Read-only controls reflect those permissions. Seed/admin
|
||||
spaces now need explicit grants; deploy/import docs reflect this behavior.
|
||||
|
||||
T08/T09 initial catalogue and review walkthrough prepared under docs/usecases/.
|
||||
Original Bubble journeys remain inferred pending observation/user confirmation;
|
||||
real cohort and release evidence remain pending. T04 continues for sharing UI,
|
||||
member export/delete, tenant filesystem invariant and original privacy parity.
|
||||
The milestone is not reached by this local implementation slice.
|
||||
|
||||
Validation: **86 tests passed**; Ruff passes changed service/view/test files;
|
||||
`git diff --check` passes for edited files. Existing
|
||||
19 repository-wide lint findings remain T07 debt; no production change made.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue