intent/docs/plan.md

211 lines
18 KiB
Markdown
Raw Normal View History

# Intent V1 implementation plan
Companion to [concept.md](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. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. |
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
### 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 per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
| 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. Uploads go to the local filesystem unless `BUCKET_NAME` is set, in which case they go to a real bucket (e.g. a Tigris dev bucket). No MinIO: its community images stopped in Oct 2025 and the repo was archived in 2026. `.env` for secrets. |
| Deploy | Fly.io, one machine, one volume. `bin/start` restores from Litestream if the volume is empty, runs migrations, then runs gunicorn under `litestream replicate`. Migrations can't use Fly's `release_command` because release machines don't mount volumes. 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.
- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`.
- Markdown headings render one level down (`#` becomes `<h2>`) so user text never adds a second `<h1>`.
## 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).
- Staff users are kept out of orgs in three places: `OrgMembership.clean()` (admin forms), the membership services, and `intent/access.py`, which grants staff nothing even if a row exists. A database constraint can't express this because it spans tables. Staff signing in through allauth are redirected to the admin login.
- 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.
- Custom `User` model with ULID key (must exist before the first migration).
- Base template: semantic layout, skip link, nav, the minimal stylesheet, dark mode toggle.
- Fly app, volume, Litestream to B2, migrations in the start script, 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.