intent/docs/plan.md
Alec Perkins 82bcdde043 docs: add concept and V1 implementation plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 22:27:06 -04:00

16 KiB
Raw Blame History

Intent V1 implementation plan

Companion to concept.md. The concept says what Intent is; this says how V1 gets built, in what order, and which details were decided by default rather than in the concept. Where the two disagree, fix the concept first.

1. V1 scope

In: organizations, projects, features (markdown, revisions, hierarchy, files), issues, comment threads (feature, line, file, image coordinate, issue), implementations as plain URLs with per-feature manual status, auth via Forgejo/GitHub/Discord/magic link with account linking, sysadmin via Django admin, three project visibility levels, open contribution toggle, invites.

Out (V1.1 or later): notifications, search, public API or MCP, Forgejo API access of any kind (issue creation, release links, visibility propagation, commit-trailer status), status inheritance through the feature hierarchy, real-time updates.

2. Decisions and defaults

Decisions made in the concept are not repeated. These fill the gaps.

Model

Topic Default
Primary keys Opaque, non-sequential: ULID stored as a 26-char string, generated in Python (python-ulid), set as default on a shared abstract base model. Never exposed as a sequence; used in URLs only where no human-readable key exists (files, file versions, threads, comments).
Feature stable ID Human-facing, separate from the PK: per-project prefix + sequence, e.g. AUTH-12. Prefix is set on project creation, uppercase, 2–8 chars, unique within the org. Number never reused.
Issue ID Per-project sequence #N, separate from feature numbers.
Feature revision Integer starting at 1, incremented by every commit to the feature: description edit, file add/replace/remove, parent change, status change. Each revision stores the full description text (not a diff); diffs are computed on display.
Commit note Required, short, free text. Optionally linked to one or more issues.
Issue ↔ feature Many-to-many. An issue promoted from a comment thread is linked to that thread's feature. Accepting an issue that creates a feature links both.
Issue statuses open, accepted, rejected, withdrawn (author closes), duplicate (links to another issue). Collaborators can reopen.
Feature statuses active, withdrawn, superseded (requires superseded_by feature).
Implementation status active, archived.
Feature × implementation status n/a, not_started, in_progress, shipped, retired; stamped with the feature revision it applies to. Missing row displays as not_started @ —. If stamped revision < current revision, display "behind".
Hierarchy Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1.
Files Belong to a feature, typed prototype or documentation. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip.
Line-anchored threads Anchor = feature revision + start/end source line. On later revisions the thread shows as "outdated" with a link to the revision it was made on; no attempt to re-map lines.
Image threads Anchor = file version + x/y as fractions (0–1).
Comments Edits keep history (visible "edited" link). Author or project owner may delete; a deleted comment shows as a tombstone. Resolving a thread is allowed for the thread author or any collaborator.
Thread → issue Original commenter or any team member. Thread gets promoted_to issue; issue body is prefilled with the thread text and links back.
Deleted users Content remains, attributed to a "ghost" user.
Moderation Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide.
Invites By email or existing username. Email invites create a pending invite, accepted via a signed link that also logs the user in (magic link). Expire after 14 days. The last org owner cannot be removed or demoted.

Stack

Topic Default
Runtime Python 3.13, Django 5.2 LTS, uv for dependencies, ruff for lint/format.
Auth django-allauth: GitHub, Discord, OpenID Connect provider configured per Forgejo instance (Forgejo supports OIDC), email login-by-code for magic links. Multiple social accounts per user via allauth's connect flow.
Sysadmins Django is_staff users with passwords, Django admin at /admin/. A model constraint rejects staff users as org members. Regular users have no password (unusable).
Database SQLite on a Fly volume. transaction_mode = "IMMEDIATE", WAL mode, busy_timeout 5 s. Litestream replicates to B2 continuously.
Files Tigris via django-storages S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed <iframe> whose src is a signed URL on the Tigris origin, never rendered inline on the app origin.
Markdown markdown-it-py with source line mapping (data-line attributes on blocks) for line threads; output sanitized with nh3. Diffs via difflib rendered server-side.
Frontend Django templates, HTMX for partial updates, Alpine for small local state (image pin placement, thread forms). CodeMirror 6 as a progressive enhancement on the markdown textarea, optional. No build step beyond copying vendored JS into static files.
Email django-anymail with the provider pluggable; console backend in development. Provider TBD; launch blocker for magic links and email invites.
Background work None. Email is sent synchronously.
Observability Sentry (free tier), Fly logs.
Tests pytest-django for models, permissions and views; a handful of Playwright smoke tests for HTMX flows.
Local dev Docker Compose: one app container (Django runserver), SQLite in a volume, MinIO as a stand-in for Tigris. .env for secrets.
Deploy Fly.io, one machine, one volume, release_command runs migrations, Litestream wraps the app process. CI assumed to be Forgejo Actions in the Intent repo: lint, test, fly deploy on main.

Styling and accessibility

The app should look close to unstyled HTML. Guiding rule: if the stylesheet fails to load, only layout suffers.

  • Semantic HTML first: <main>, <nav>, <article>, <section>, <h1>–<h6> in order, <button> for actions, <a> for navigation, <table> for tabular data, <details> for collapsibles, <dialog> where a modal is needed.
  • One small stylesheet (target under 5 KB). It sets: color-scheme: light dark on :root, a readable max line width, spacing, focus-visible outline, and a few utilities. Colors come from light-dark() or system colors; no custom palette beyond a link color and a muted text color.
  • Dark mode via prefers-color-scheme from day one, with a three-way toggle (system/light/dark) stored in localStorage and applied via data-theme on <html>.
  • Every HTMX interaction has a non-JS fallback: forms post normally, links navigate. HTMX enhances, never gates.
  • Keyboard: all actions reachable by tab; skip link to <main>; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
  • Contrast and semantics checked with axe in the Playwright smoke tests.

3. Data model

Fields are indicative, not exhaustive. All models have a ULID id primary key plus created_at/updated_at via a common abstract base. Human-facing keys (Organization.slug, Project.slug, feature AUTH-12, issue #N) are separate columns used in URLs; the ULID appears in URLs only for models without one. Soft deletes only where noted.

User                 allauth user; no password for regular users; is_staff = sysadmin
Organization         slug, name
OrgMembership        org, user, role ∈ {owner, collaborator}, can_create_projects: bool
OrgInvite            org, email | user, role, token, expires_at, accepted_at
Project              org, slug, name, feature_prefix, visibility ∈ {private, authenticated, public},
                     open_contribution: bool, next_feature_number, next_issue_number
ProjectMembership    project, user, role ∈ {owner, collaborator}
Feature              project, number, parent (nullable, same project), title, status, superseded_by,
                     current_revision (int), created_by
FeatureRevision      feature, revision, description_md, note, author, parent_at_revision,
                     status_at_revision, issues (M2M)
FeatureFile          feature, kind ∈ {prototype, documentation}, name, current_version
FeatureFileVersion   file, version, storage_key, content_type, size, created_in_revision
Implementation       project, name, url, status ∈ {active, archived}
FeatureImplStatus    feature, implementation, status, feature_revision, updated_by   (unique feature+impl)
Issue                project, number, title, body_md, author, status, duplicate_of,
                     features (M2M), promoted_from_thread (nullable)
CommentThread        project, target: one of {feature, feature_line, feature_file, file_image_point, issue},
                     feature, issue, revision, line_start, line_end, file_version, x, y,
                     resolved_by, resolved_at, promoted_to (issue)
Comment              thread, author, body_md, deleted_at
CommentEdit          comment, body_md, edited_at

Notes:

  • FeatureRevision is append-only. Feature holds denormalized current values for cheap listing.
  • Feature number and issue number are allocated inside a transaction with select_for_update on the project row (fine under SQLite's single-writer model).
  • A CheckConstraint on OrgMembership rejects user.is_staff. Enforced again in a model clean() for admin forms.
  • Thread target is validated in clean(): exactly the fields required for its target type are set.

4. Permissions

Resolved per request into a small Access object (can_view, can_comment, can_create_issue, can_edit_features, can_manage_impl, can_admin) and passed to templates. One module, well tested, used everywhere.

Action Anonymous Logged-in non-member Org collaborator (no project grant) Project collaborator Project owner / org owner
View public project ✓ ✓ ✓ ✓ ✓
View authenticated project ✓ ✓ ✓ ✓
View private project ✓ ✓
Comment, create issue if open contribution if open contribution ✓ ✓
Promote own thread to issue if open contribution if open contribution ✓ ✓
Promote any thread to issue ✓ ✓
Accept/reject/reopen issue ✓ ✓
Create/edit feature, files, status ✓ ✓
Link implementations, set statuses ✓ ✓
Resolve threads, delete comments own own own ✓ ✓
Manage project members, delete project ✓
Create project if can_create_projects org owner ✓
Manage org members, grants org owner
Create organization sysadmin only

Org owners are treated as project owners on every project in the org without needing a membership row.

5. Phases

Each phase ends deployable. Rough sizes assume one person, part-time; adjust freely.

Phase 0 — Skeleton and deploy (small)

  • Repo, uv project, Django 5.2, settings split (base/dev/prod), ruff, pytest-django.
  • Abstract base model with ULID primary key and timestamps; all models inherit it.
  • Docker Compose for local dev with MinIO.
  • Base template: semantic layout, skip link, nav, the minimal stylesheet, dark mode toggle.
  • Fly app, volume, Litestream to B2, release_command for migrations, Sentry.
  • CI: lint + test + deploy on main.
  • Exit: empty app with a health page running on Fly; restore from Litestream tested once.

Phase 1 — Accounts and organizations (medium)

  • allauth with GitHub, Discord, one Forgejo OIDC provider, email login-by-code (console backend).
  • Account linking UI (allauth connections page, restyled minimally).
  • Sysadmin: Django admin, create orgs with initial owner.
  • Org pages: member list, roles, can_create_projects, invites by email/username, accept/decline, leave org, last-owner guard.
  • Permissions module with tests.
  • Exit: sysadmin creates an org; owner invites a user; user logs in with two linked providers.

Phase 2 — Projects and features (large)

  • Project create (prefix, visibility, open contribution), member management, delete.
  • Feature CRUD as revisions: create, edit description with required note, change parent, change status, superseded_by.
  • Revision history page, diff view between any two revisions, per-revision read-only view.
  • Feature tree view (nested <ul>/<details>), flat list, feature page with children listed.
  • Markdown rendering with line mapping and sanitization.
  • Exit: full feature lifecycle works without JS enabled.

Phase 3 — Issues and comments (large)

  • Issue CRUD, statuses, duplicate linking, M2M to features, "accept and create feature" flow prefilling the feature from the issue.
  • Comment threads on issues and on features (general), with resolve, edit history, tombstone delete.
  • Line-anchored threads: gutter buttons per rendered block, thread list beside the description, outdated marking on newer revisions.
  • Promote thread to issue.
  • HTMX partials for posting comments and resolving threads, with form fallbacks.
  • Exit: the example flow in concept.md (steps 1–7) works end to end.

Phase 4 — Files and implementations (medium)

  • File upload to Tigris, versions, signed URLs, sandboxed iframe for HTML, image display.
  • Image-coordinate threads (Alpine for pin placement, list fallback).
  • File-level threads.
  • Implementations: CRUD, URL, status; per-feature status table on the implementation page and per-implementation status on the feature page; "behind" indicator against current revision.
  • Exit: a feature with a prototype image has a pinned thread; an implementation shows one feature shipped and one behind.

Phase 5 — Launch hardening (small)

  • Pick email provider, configure anymail, verify magic link and invite emails.
  • Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion.
  • Playwright smoke tests with axe checks on the main pages in both color schemes.
  • Rate limiting on login and comment endpoints (django-ratelimit), CSP header, security headers review.
  • Backup restore drill repeated against the real volume.
  • Exit: launch checklist (below) complete.

6. Launch checklist

  • Email provider configured; magic link and invite emails tested in production
  • Litestream restore verified from B2 into a fresh volume
  • Fly volume snapshots enabled as a second backup
  • Sentry receiving errors from production
  • At least one Forgejo OIDC provider registered and tested
  • axe passes on feature, issue, project and org pages, light and dark
  • Keyboard-only walkthrough of the example flow
  • Privacy check: private project content not reachable via file URLs, feeds, or admin leaks

7. Hooks for V1.1

Decisions made now so V1.1 does not need a migration redesign:

  • Implementation.url is kept separate from future forgejo_instance and repo_path fields; the URL stays the display link.
  • FeatureImplStatus already records feature_revision, which is what commit trailers will set (Intent: AUTH-12@7).
  • Org-level ForgejoCredential (instance URL, service-user token) is the planned home for sync credentials; nothing in V1 depends on it.
  • Feature stable IDs and revision numbers are never reused or renumbered, so external references stay valid.