# 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 any closed issue; authors can withdraw their own open issue and reopen it only if they withdrew it. Duplicate chains can't loop. Every status change, feature link and edit is an `IssueEvent` shown in the issue's timeline between comments; edits keep the previous title and text. Closed issues still take comments. | | Issue → feature | From an issue, a collaborator follows "Create a feature from #N" or "Update INT-3 for #N". The feature form opens prefilled (title and description from the issue, or a change note naming it), ties the new revision to the issue, links the feature, and offers a checked "Mark #N accepted" box. Accept and Reject buttons also work on their own. | | 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". Each implementation page is one form: a status select per feature, plus a "Still … at revision N" checkbox on rows that are behind, which re-stamps without changing the status. Only changed or confirmed rows are written. Implementation links must be http(s). Collaborators create, edit and archive implementations; project owners delete them (typed name to confirm). | | 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`. Adding, replacing or removing a file is a feature revision with a required change note; the revision records what happened ("Added prototype “a.png”") and shows it in history. Replacing creates a new file version; old versions stay (so old image-pin threads still resolve). Removed files stay too, so old revisions list the files they had. Size cap 25 MB; allowed types: images (png, jpg, gif, webp, avif, svg), PDF, Markdown/text, HTML, zip. The content type comes from the extension, never the browser. Stored under opaque paths (`files//`). Markdown files render on their page; text shows as text; PDFs and zips open or download. | | Line-anchored threads | Anchor = feature revision + start/end source line of one top-level Markdown block (paragraph, list, heading, code block). Each block has its own `
` comment form, so it works without JS. A thread goes "outdated" only when the description text has changed since its revision (a status-only revision doesn't outdate it); outdated threads move to "Comments on earlier revisions", quote their original lines, and link to the revision. No attempt to re-map lines. A line comment posted against a stale revision is refused and the draft is shown back. | | Image threads | Anchor = file version + x/y as fractions (0–1). Pins are drawn as an SVG overlay using percentage coordinates (no inline styles), numbered per version, and each is a link to its thread. Placing a pin: two number fields (across %, down %) work without JS; clicking the image fills them in. New comments go on the latest version only; threads on other versions are listed separately with a link to that version. | | File threads | Anchor = file version, like image threads without a point. | | Comments | Edits keep history (visible "edited" link to a history page). Author or project owner may delete; a deleted comment shows as a tombstone and its text and history are removed. Delete asks for confirmation in a disclosure, so it works without JS. Resolving a feature thread is allowed for the thread author or any collaborator; resolved threads start collapsed. An issue has exactly one thread, its discussion, which isn't resolvable (the issue status says that). | | Thread → issue | Original commenter (while they can contribute) or any project collaborator. Thread gets `promoted_to` issue and is resolved; the issue is linked to the thread's feature, its body is prefilled with the quoted lines and comments, and its page links back to the thread. | | 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 the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. | | URLs | Organizations live at the root: `//`, `///`, `///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, for missing and hidden projects alike, so the redirect reveals nothing either. Markdown drops remote images (``) until uploads exist, so an author can't log readers' IP addresses. | | 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, and allauth's password URLs are not routed at all. Adding an email address requires re-entering a fresh code, and existing addresses are notified. 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). Only superusers can grant staff or superuser status or permissions in the admin. | | Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. | | 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. Every file URL is an app view (`…/files//v/`) that checks project access first, then redirects to a 5-minute signed URL. Where the bucket isn't configured (dev), the view streams the file itself with `nosniff` and a `Content-Security-Policy: sandbox` header, so an uploaded SVG or HTML file can't act as the app. HTML prototypes are shown in an `