Add Space metadata model and provisional Forgejo content ADR

Introduce tenant-scoped Space/SpaceMembership with list and detail views.
Document markdown-in-Forgejo as content SoR (ADR-0002). Run migrations on
container start so app.coulomb.social picks up the new tables.
This commit is contained in:
tegwick 2026-08-12 01:30:05 +02:00
parent bb6bc6093a
commit be32432d39
22 changed files with 697 additions and 12 deletions

View file

@ -36,12 +36,14 @@ WORKDIR /app
COPY --from=deps /app/.venv /app/.venv
COPY manage.py pyproject.toml ./
COPY coulomb_social ./coulomb_social
COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chown -R appuser:appuser /app
RUN chmod +x /app/docker-entrypoint.sh \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')" || exit 1
CMD ["gunicorn", "coulomb_social.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View file

@ -74,6 +74,7 @@ The rebuild is deliberately **product-faithful first**: UI and content parity ma
- `workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md`**done**: identity shell on app.coulomb.social
- `workplans/CSOC-WP-0004-app-shell-and-space-content.md`**current path**: leave login shell; spaces + Forgejo markdown
- `docs/adr/ADR-0001-netkingdom-identity.md` — accepted identity decision
- `docs/adr/ADR-0002-space-content-forgejo-markdown.md` — space content as markdown in Forgejo
- `workplans/CSOC-WP-0001-bubble-io-exit-assessment.md` — Bubble inventory/migration **after** product foundation
- `workplans/CSOC-WP-0003-self-registration-and-assurance.md` — public registration when NetKingdom mail path lands
- `the-custodian/docs/coulomb-social-rebuild-seed.md` — original workplan seed (CUST-WP-0058-T08)

View file

@ -68,7 +68,8 @@ coulomb.social connects people around shared projects and complementary capabili
- Status: **parallel hosts** — identity live; product content under construction
- **`https://app.coulomb.social`**: Railiance rebuild (OIDC, shell); spaces/content next
- **`https://coulomb.social`**: Bubble.io full product until cutover decision
- Active path: **`CSOC-WP-0004`** (app shell + Forgejo markdown spaces)
- Active path: **`CSOC-WP-0004`** (spaces metadata + Forgejo markdown content)
- Content ADR: `docs/adr/ADR-0002-space-content-forgejo-markdown.md`
- Deferred: bulk Bubble migration (`CSOC-WP-0001`); public self-registration (`CSOC-WP-0003` / NK)
- Prior art: design extract; CSOC-WP-0002 identity acceptance (2026-08-10)

View file

@ -2,6 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from coulomb_social.apps.spaces.services import spaces_for_member
from .principal import build_principal
@ -19,8 +21,8 @@ def app_home(request: HttpRequest) -> HttpResponse:
f"Not authorized to view shell ({principal['authz_reason']})."
)
# Spaces list is empty until CSOC-WP-0004-T02; keep the product empty-state.
spaces: list[dict] = []
member = principal.get("member")
spaces = list(spaces_for_member(member))
return render(
request,
"core/app_home.html",

View file

View file

@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Space, SpaceMembership
class SpaceMembershipInline(admin.TabularInline):
model = SpaceMembership
extra = 0
raw_id_fields = ("member",)
@admin.register(Space)
class SpaceAdmin(admin.ModelAdmin):
list_display = (
"title",
"slug",
"tenant_id",
"is_active",
"forgejo_owner",
"forgejo_repo",
"updated_at",
)
list_filter = ("tenant_id", "is_active")
search_fields = ("title", "slug", "forgejo_repo")
prepopulated_fields = {"slug": ("title",)}
raw_id_fields = ("created_by",)
inlines = [SpaceMembershipInline]
@admin.register(SpaceMembership)
class SpaceMembershipAdmin(admin.ModelAdmin):
list_display = ("space", "member", "role", "created_at")
list_filter = ("role",)
raw_id_fields = ("space", "member")

View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class SpacesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "coulomb_social.apps.spaces"
label = "spaces"

View file

@ -0,0 +1,150 @@
# Generated by Django 6.1 on 2026-08-11 23:29
import coulomb_social.apps.spaces.models
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("members", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Space",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("tenant_id", models.CharField(db_index=True, max_length=64)),
(
"slug",
models.SlugField(
max_length=80,
validators=[
coulomb_social.apps.spaces.models.validate_space_slug
],
),
),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"forgejo_owner",
models.CharField(
blank=True,
help_text="Forgejo org or user owning the repo (e.g. coulomb).",
max_length=128,
),
),
(
"forgejo_repo",
models.CharField(
blank=True,
help_text="Repository name (e.g. space-my-space).",
max_length=128,
),
),
(
"default_branch",
models.CharField(blank=True, default="main", max_length=128),
),
(
"content_root",
models.CharField(
blank=True,
default="pages",
help_text="Path prefix inside the repo for markdown pages.",
max_length=255,
),
),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="spaces_created",
to="members.member",
),
),
],
options={
"ordering": ["title", "slug"],
},
),
migrations.CreateModel(
name="SpaceMembership",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=16,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"member",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="space_memberships",
to="members.member",
),
),
(
"space",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="spaces.space",
),
),
],
),
migrations.AddIndex(
model_name="space",
index=models.Index(
fields=["tenant_id", "is_active"], name="spaces_spac_tenant__c3a447_idx"
),
),
migrations.AddConstraint(
model_name="space",
constraint=models.UniqueConstraint(
fields=("tenant_id", "slug"), name="spaces_space_tenant_slug_uniq"
),
),
migrations.AddConstraint(
model_name="spacemembership",
constraint=models.UniqueConstraint(
fields=("space", "member"), name="spaces_membership_space_member_uniq"
),
),
]

View file

@ -0,0 +1,115 @@
"""Space metadata — content bodies live in Forgejo (markdown), not here."""
from __future__ import annotations
import re
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def validate_space_slug(value: str) -> None:
if not _SLUG_RE.match(value or ""):
raise ValidationError(
"Slug must be lowercase alphanumeric with single hyphens (e.g. my-space)."
)
class Space(models.Model):
"""Tenant-scoped co-creation space.
Postgres holds metadata and Forgejo binding pointers only. Long-form pages
are markdown in the bound repository (ADR-0002).
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant_id = models.CharField(max_length=64, db_index=True)
slug = models.SlugField(max_length=80, validators=[validate_space_slug])
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
created_by = models.ForeignKey(
"members.Member",
on_delete=models.PROTECT,
related_name="spaces_created",
null=True,
blank=True,
)
# Optional content binding (Forgejo) — empty until T04 wires the read path
forgejo_owner = models.CharField(
max_length=128,
blank=True,
help_text="Forgejo org or user owning the repo (e.g. coulomb).",
)
forgejo_repo = models.CharField(
max_length=128,
blank=True,
help_text="Repository name (e.g. space-my-space).",
)
default_branch = models.CharField(max_length=128, blank=True, default="main")
content_root = models.CharField(
max_length=255,
blank=True,
default="pages",
help_text="Path prefix inside the repo for markdown pages.",
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tenant_id", "slug"],
name="spaces_space_tenant_slug_uniq",
),
]
indexes = [
models.Index(fields=["tenant_id", "is_active"]),
]
ordering = ["title", "slug"]
def __str__(self) -> str:
return f"{self.title} ({self.slug})@{self.tenant_id}"
@property
def has_content_binding(self) -> bool:
return bool(self.forgejo_owner and self.forgejo_repo)
@classmethod
def suggest_slug(cls, title: str) -> str:
return slugify(title)[:80] or "space"
class SpaceMembership(models.Model):
"""Optional explicit membership; v1 list also allows tenant-wide visibility."""
class Role(models.TextChoices):
OWNER = "owner", "Owner"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
space = models.ForeignKey(Space, on_delete=models.CASCADE, related_name="memberships")
member = models.ForeignKey(
"members.Member",
on_delete=models.CASCADE,
related_name="space_memberships",
)
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["space", "member"],
name="spaces_membership_space_member_uniq",
),
]
def __str__(self) -> str:
return f"{self.member_id}@{self.space.slug}:{self.role}"

View file

@ -0,0 +1,39 @@
"""Space queries scoped by tenant membership."""
from __future__ import annotations
from django.db.models import Q, QuerySet
from coulomb_social.apps.members.models import Member
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.
"""
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()
.order_by("title", "slug")
)
def get_space_for_member(member: Member | None, slug: str) -> Space | None:
if member is None:
return None
return spaces_for_member(member).filter(slug=slug).first()

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = "spaces"
urlpatterns = [
path("<slug:slug>/", views.space_detail, name="detail"),
]

View file

@ -0,0 +1,34 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound
from django.shortcuts import render
from coulomb_social.apps.core.principal import build_principal
from .services import get_space_for_member, spaces_for_member
@login_required
def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
principal = build_principal(request, authz_resource_id="space_detail")
if not principal["authz_allow"]:
return HttpResponseForbidden(
f"Not authorized to view spaces ({principal['authz_reason']})."
)
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
return render(
request,
"spaces/detail.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
},
)
# used by core.app_home — re-export list helper
def list_spaces_context(member) -> dict:
return {"spaces": list(spaces_for_member(member))}

View file

@ -21,6 +21,7 @@ INSTALLED_APPS = [
"coulomb_social.apps.core",
"coulomb_social.apps.members",
"coulomb_social.apps.identity",
"coulomb_social.apps.spaces",
]
MIDDLEWARE = [

View file

@ -4,15 +4,20 @@
<h1>Spaces</h1>
<p class="muted">
Co-creation spaces for your tenant.
Content will live as markdown in Forgejo-backed repositories.
Page content will live as markdown in Forgejo-backed repositories.
</p>
{% if spaces %}
<ul class="space-list" style="list-style:none;padding:0;margin:1.5rem 0 0;">
{% for space in spaces %}
<li class="card" style="margin-top:0.75rem;">
<strong>{{ space.title }}</strong>
<span class="muted"> · {{ space.slug }}</span>
<a href="{% url 'spaces:detail' space.slug %}" style="color:inherit;text-decoration:none;">
<strong>{{ space.title }}</strong>
<span class="muted"> · {{ space.slug }}</span>
</a>
{% if space.description %}
<p class="muted" style="margin:0.35rem 0 0;font-size:0.95rem;">{{ space.description }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
@ -20,8 +25,8 @@
<div class="card">
<h2 style="margin-top:0;">No spaces yet</h2>
<p class="muted" style="margin-bottom:0;">
Spaces and Forgejo-backed content land in the next steps
(CSOC-WP-0004-T02+). Use the profile menu for session diagnostics.
Operators can seed a space via Django admin (tenant-matched).
Create-space UI and Forgejo binding land next (T04T06).
</p>
</div>
{% endif %}

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ space.title }} — {{ site_name }}{% endblock %}
{% block content %}
<p class="muted" style="margin:0 0 0.5rem;">
<a href="{% url 'core:app_home' %}">← Spaces</a>
</p>
<h1>{{ space.title }}</h1>
{% if space.description %}
<p class="muted">{{ space.description }}</p>
{% endif %}
<div class="card">
<h2 style="margin-top:0;">Space metadata</h2>
<dl>
<dt>Slug</dt><dd>{{ space.slug }}</dd>
<dt>Tenant</dt><dd>{{ space.tenant_id }}</dd>
<dt>Content</dt>
<dd>
{% if space.has_content_binding %}
{{ space.forgejo_owner }}/{{ space.forgejo_repo }}
@ {{ space.default_branch }}
· root <code>{{ space.content_root }}</code>
{% else %}
Not bound to a Forgejo repo yet (CSOC-WP-0004-T04).
{% endif %}
</dd>
</dl>
</div>
<div class="card">
<h2 style="margin-top:0;">Pages</h2>
<p class="muted" style="margin-bottom:0;">
Markdown page rendering from Forgejo lands in T04. This view is metadata only.
</p>
</div>
{% endblock %}

View file

@ -5,5 +5,6 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("healthz", include("coulomb_social.apps.core.urls_health")),
path("auth/", include("coulomb_social.apps.identity.urls")),
path("app/spaces/", include("coulomb_social.apps.spaces.urls")),
path("", include("coulomb_social.apps.core.urls")),
]

View file

@ -0,0 +1,123 @@
# ADR-0002 — Space content as markdown in Forgejo
| Field | Value |
|-------|--------|
| Status | **Provisional** (accepted for T04 vertical slice; revisit write path) |
| Date | 2026-08-12 |
| Deciders | bernd |
| Workplan | CSOC-WP-0004-T03 |
## Context
coulomb.social co-creation **spaces** need durable page/artefact content.
Bubble stores opaque page graphs. The rebuild should:
- keep content **agent- and human-editable** in git,
- version and review changes like normal software,
- avoid making Postgres the system of record for long-form prose,
- align with Forgejo already hosting fleet code on railiance01.
Identity and space **metadata** stay in the app DB (`Space`, memberships).
## Decision
### 1. Canonical content form
- **Markdown** (CommonMark + limited GFM: tables, fenced code, images).
- Static assets (images, attachments) live beside markdown under a content root.
- Postgres stores **metadata and binding only** — never full page bodies as SoR.
### 2. Repository layout (v1)
**One Forgejo repository per space** under a dedicated org (recommended:
`coulomb-spaces` or tenant-scoped org later).
```text
<forgejo_owner>/<forgejo_repo>/
README.md # optional space intro
pages/
index.md # default landing page
<page-slug>.md
assets/ # images etc. referenced from pages
```
| Field on `Space` | Meaning |
|------------------|---------|
| `forgejo_owner` | org/user |
| `forgejo_repo` | repo name |
| `default_branch` | usually `main` |
| `content_root` | default `pages` |
**Branch policy (v1):** app reads from `default_branch` only. PRs for review
are a human/Forgejo workflow; the app does not merge PRs in v1.
**Monorepo alternative (deferred):** single repo with `spaces/<slug>/…`
rejected for v1 to keep permissions and migration packages simple.
### 3. Read path (T04)
1. App resolves `Space` by tenant + slug (authz already enforced).
2. If binding incomplete → empty state (no silent Bubble fetch).
3. Fetch file via **Forgejo raw/contents API** (preferred) or shallow cache:
- HTTP GET with service token from env/OpenBao.
- Cache rendered HTML or raw markdown in memory/disk with short TTL +
optional webhook invalidation later.
4. Render markdown → HTML with a locked-down sanitizer (no raw script).
5. Fail closed on 404/403/network errors with operator-visible reason.
### 4. Write path (T05 — provisional)
**v1 preference:** **edit in Forgejo** (web UI or git) + app refresh.
- Lowest security surface (no app-held write credentials required if public-read
internal repos use deploy token read-only).
- Optional later: in-app editor → commit as bot user via API.
**Not in v1:** bidirectional live sync with Bubble.
### 5. Mapping from Bubble (later CSOC-WP-0001)
| Bubble concept | Target |
|----------------|--------|
| Space / room-like container | `Space` row + Forgejo repo |
| Page / chunk prose | `pages/<slug>.md` |
| Attachments | `assets/…` |
| Permissions | `SpaceMembership` + NetKingdom groups (refine later) |
Export scripts should emit markdown files + a manifest JSON for binding fields.
### 6. Secrets
| Secret | Storage |
|--------|---------|
| Forgejo API token (read, later write) | K8s Secret / OpenBao — env e.g. `FORGEJO_TOKEN` |
| Forgejo base URL | non-secret config e.g. `FORGEJO_BASE_URL=https://forgejo.coulomb.social` |
Never commit tokens. Never render tokens in Session details.
## Consequences
**Positive**
- Content is git-native and agent-friendly.
- Clear boundary: app = membership + UX; Forgejo = document history.
- Migration can ship repos per space without rewriting history into SQL.
**Negative / follow-ups**
- Need Forgejo org, tokens, and network reachability from the app pod.
- Offline/local dev needs a stub or fixture markdown path.
- Search and cross-space queries need a separate index later.
## Open questions (non-blocking for T04)
1. Org name: `coulomb-spaces` vs per-tenant org.
2. Private repos only vs public read for some community spaces.
3. When to add webhook-driven cache purge.
4. Whether user OAuth to Forgejo is required for in-app write (T05).
## References
- CSOC-WP-0004, Space model in `coulomb_social.apps.spaces`
- ADR-0001 NetKingdom identity
- Host posture: `docs/deploy.md` (app.coulomb.social)

4
scripts/docker-entrypoint.sh Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
set -e
python manage.py migrate --noinput
exec gunicorn coulomb_social.wsgi:application --bind 0.0.0.0:8000 --workers 2

111
tests/test_spaces.py Normal file
View file

@ -0,0 +1,111 @@
import pytest
from django.urls import reverse
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.services import spaces_for_member
def _login(client, settings, *, subject: str, tenant: str, name: str):
settings.DEBUG = True
settings.OIDC_ENABLED = False
settings.DEFAULT_TENANT_ID = tenant
return client.post(
reverse("identity:dev_login"),
{
"subject": subject,
"issuer": "https://local.dev/issuer",
"name": name,
"tenant": tenant,
},
)
@pytest.mark.django_db
def test_spaces_list_empty_for_tenant(client, settings):
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
r = client.get(reverse("core:app_home"))
assert r.status_code == 200
assert b"No spaces yet" in r.content
@pytest.mark.django_db
def test_spaces_list_shows_same_tenant_only(client, settings):
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
member = Member.objects.get(subject="u1")
Space.objects.create(
tenant_id="tenant:a",
slug="alpha",
title="Alpha Space",
created_by=member,
)
Space.objects.create(
tenant_id="tenant:b",
slug="beta",
title="Beta Space",
)
r = client.get(reverse("core:app_home"))
assert r.status_code == 200
assert b"Alpha Space" in r.content
assert b"Beta Space" not in r.content
assert b"No spaces yet" not in r.content
@pytest.mark.django_db
def test_space_detail_tenant_isolation(client, settings):
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
Space.objects.create(tenant_id="tenant:b", slug="secret", title="Secret")
r = client.get(reverse("spaces:detail", kwargs={"slug": "secret"}))
assert r.status_code == 404
@pytest.mark.django_db
def test_space_detail_ok(client, settings):
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
member = Member.objects.get(subject="u1")
Space.objects.create(
tenant_id="tenant:a",
slug="lab",
title="Lab",
description="Research lab",
created_by=member,
forgejo_owner="coulomb",
forgejo_repo="space-lab",
)
r = client.get(reverse("spaces:detail", kwargs={"slug": "lab"}))
assert r.status_code == 200
assert b"Lab" in r.content
assert b"coulomb/space-lab" in r.content
@pytest.mark.django_db
def test_membership_narrows_visibility(client, settings):
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
member = Member.objects.get(subject="u1")
other_user = User.objects.create_user(username="other")
other = Member.objects.create(
tenant_id="tenant:a",
user=other_user,
user_engine_user_id="usr_other",
issuer="https://local.dev/issuer",
subject="other-sub",
display_name="Other",
)
open_space = Space.objects.create(
tenant_id="tenant:a", slug="open", title="Open", created_by=other
)
closed = Space.objects.create(
tenant_id="tenant:a", slug="closed", title="Closed", created_by=other
)
# Any membership in tenant switches mode to membership-scoped
SpaceMembership.objects.create(
space=closed, member=other, role=SpaceMembership.Role.OWNER
)
SpaceMembership.objects.create(
space=open_space, member=member, role=SpaceMembership.Role.MEMBER
)
visible = list(spaces_for_member(member).values_list("slug", flat=True))
assert "open" in visible
assert "closed" not in visible

View file

@ -75,7 +75,7 @@ dump removed from home body. Deploy with next image for app.coulomb.social.
```task
id: CSOC-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "5fd91718-151d-4216-acf2-2104a45cddf9"
```
@ -92,11 +92,14 @@ Migrations + admin + minimal list/detail routes behind auth.
**Done when:** authenticated user can list zero-or-more spaces from DB; tests cover
tenant isolation basics.
2026-08-12: `spaces` app — `Space` + `SpaceMembership`, tenant-scoped list on
`/app/`, detail `/app/spaces/<slug>/`, admin seed path. Tests cover isolation.
## T03 — Content model ADR: markdown + Forgejo
```task
id: CSOC-WP-0004-T03
status: todo
status: done
priority: high
state_hub_task_id: "628243e0-732f-42c1-b4e3-9b8cd82ea530"
```
@ -113,6 +116,9 @@ Write `docs/adr/ADR-0002-space-content-forgejo-markdown.md` deciding:
**Done when:** ADR accepted (or explicitly provisional with open questions listed)
and linked from INTENT/SCOPE.
2026-08-12: Provisional ADR-0002 committed; linked from INTENT/SCOPE. One repo
per space, `pages/` root, Forgejo API read for T04; write-in-Forgejo for T05.
## T04 — Read path: render space markdown from bound repo
```task
@ -170,6 +176,7 @@ names, smoke checklist on app.coulomb.social. Update `docs/deploy.md` and
id: CSOC-WP-0004-T07
status: done
priority: high
state_hub_task_id: "8487443a-dfd2-4220-a90b-bca2b5b04c4c"
```
Keep the current principal card fields available as **detail information**