User stories
Every story names its routes and the test that proves it. Proof is a venom testcase (tests/venom.yml), a playwright spec (tests/playwright/specs/), a Go test package, or untested. The persona journeys under tests/playwright/specs/journey-*.spec.ts walk a visitor, reader, writer and owner end to end in a browser, overlapping the venom rows below; a story cites the most specific proof it has. untested names a capability no automated test exercises yet.
The cast
visitor - no account. Reads what is public: the firehose at /, public projects and their published articles, tag hubs, search, the Atom feeds and the site documents. Registration and the sign-in doors are the only verbs.
reader - a registered account, no membership. The audience verbs separate them from the visitor: follow, subscribe, like, bookmark, a /feed of their own, notifications, /settings and the export. Reading stays free.
member - a reader who paid the one-time fee (user.member_since). Writing opens: projects, articles and reposts, comments, uploads. Every role below requires it.
project owner - the member who created a project, or accepted an invitation as owner. Runs it: settings, visibility, invitations, the comment queue, the trash, every article in it.
co-writer - a member holding the member role in someone else’s project. An author there: writes, edits drafts and reviews, publishes their own work. Settings and the comment queue stay with the owners.
reviewer - the reviewer role: reads articles in the review and scheduled states before publication and posts review-flagged feedback in the thread. Writes no articles.
subscriber / follower - a reader attached to a source: following a user or a tag, subscribed to a project. An audience relation: it feeds /feed and the notifications, and grants no pre-publish access.
operator - whoever runs the blog CLI on the host. Grants and revokes memberships, audits and restores accounts, suspends, purges articles, restores projects, snapshots and imports. Every verb is a command.
The stories
Visitor
| Story | Routes | Proof |
|---|---|---|
| A visitor can read the public firehose | GET / | venom: landing page renders for an anonymous visitor |
| A visitor can read a public project’s page | GET /u/{username}/{project} | venom: /search finds published articles and misses drafts and private ones |
| A visitor gets a 404 from a private project, page and feed alike | GET /u/{username}/{project}, GET /p/{project}/feed.xml | venom: a private journal does not exist for strangers |
| A visitor can read a published article in a project | GET /u/{username}/{project}/{article} | venom: a draft with markdown publishes and renders |
| A visitor can read a project-less article under its author | GET /u/{username}/{slug} | venom: a project-less article has its own drafts, trash and fanout |
| A visitor holds one address per article | GET /u/{username}/{project}/{article} | venom: an article moves to another project and takes its url with it; a project-less article has its own drafts, trash and fanout |
| A visitor can follow a shortlink to what they may read, and get a 404 otherwise | GET /_/{id} | venom: the shortlink decides for its reader |
| A visitor sees a scheduled article as a countdown teaser | GET /u/{username}/{project}/{article} | venom: a scheduled article shares as a countdown teaser |
| A visitor can read a user’s publishing page | GET /u/{username} | venom: a javascript link in a bio renders as text |
| A visitor can browse a tag hub | GET /t/{tag}, GET /u/{username}/t/{tag} | venom: the tag hub lists the article |
| A visitor can search published articles, site-wide or in one project | GET /search | venom: /search finds published articles and misses drafts and private ones |
| A visitor can read the site documents | GET /privacy, /terms-of-use, /contact, /security | venom: the site documents render with chrome and footer links |
| A visitor can subscribe to a user’s or a public project’s Atom feed | GET /u/{username}/feed.xml, GET /p/{project}/feed.xml | venom: the blog and user feeds answer valid atom |
| A visitor can read a public reading list | GET /u/{username}/bookmarks | untested |
| A visitor can read a public reading list’s Atom twin | GET /u/{username}/bookmarks.xml | venom: the bookmark feed honours its three visibility settings |
| A visitor can register, 18 or over, agreeing to the terms | GET /register, POST /register | venom: registration signs the browser in; registration refuses anyone under 18 and keeps nothing |
| A visitor can sign in with username or email and password | GET /login, POST /login | venom: login refuses a wrong password without naming the account; the password changes in /settings and the old one stops signing in |
| A visitor can request a sign-in link and get one answer whatever the address | POST /login/link, GET /login/link/{token} | venom: a sign-in link answers one screen for every address |
Reader
| Story | Routes | Proof |
|---|---|---|
| A reader lands on their feed, which says what fills it when empty | GET /feed | venom: an empty dashboard says what fills it and where to go |
| A reader can read their feed as Atom, theirs alone by session | GET /feed.xml | venom: /feed.xml is the session holder’s own feed |
| A reader can follow a user | POST /u/{username}/follow | venom: a second account follows, subscribes, and gets one notification |
| A reader can unfollow a user | POST /u/{username}/unfollow | venom: the audience edges are reviewable at /settings/following |
| A reader can subscribe to a project | POST /p/{project}/subscribe | venom: a second account follows, subscribes, and gets one notification |
| A reader can unsubscribe from a project | POST /p/{project}/unsubscribe | untested |
| A reader can follow a tag, which lists public articles only | POST /t/{tag}/follow | venom: a followed tag lists public articles in the feed, and no others |
| A reader can unfollow a tag | POST /t/{tag}/unfollow | untested |
| A reader can review and revoke every edge in one place | GET /settings/following | venom: the audience edges are reviewable at /settings/following |
| A reader can like what they can read, idempotently, and nothing they cannot | POST /articles/{id}/like | venom: likes and bookmarks count, and refuse what the reader cannot read |
| A reader can unlike | POST /articles/{id}/unlike | untested |
| A reader can bookmark what they can read | POST /articles/{id}/bookmark | venom: likes and bookmarks count, and refuse what the reader cannot read |
| A reader can unbookmark | POST /articles/{id}/unbookmark | untested |
| A reader’s reading list sits behind its visibility setting | GET /u/{username}/bookmarks.xml | venom: the bookmark feed honours its three visibility settings |
| A reader can see what reached them | GET /notifications | venom: a second account follows, subscribes, and gets one notification |
| A reader can mark their notifications read | POST /notifications/read | untested |
| A reader sees their initials disc and the unread bubble in the chrome | GET /feed | venom: the account disc shows the initials and the unread bubble; playwright: chrome.spec.ts “the account menu opens as a popover and names the account” |
| A reader can edit their display name and bio, with hostile input rendered as text | GET /settings, POST /settings | venom: a javascript link in a bio renders as text; the settings refusals answer 422, name the field and keep the typing |
| A reader can change their password behind the current one | POST /settings | venom: the password changes in /settings and the old one stops signing in |
| A reader can change their email behind the password, audited | POST /settings | venom: the audit log records an email change and the operator can restore it |
| A reader can change their username; the old address 404s, the shortlink holds | POST /settings | venom: a rename in /settings moves the page and the old address 404s |
| A reader can sign out every other session | POST /settings | venom: sign out everywhere keeps the acting session only |
| A reader can delete their account behind the password | POST /settings | venom: self-serve deletion closes the account |
| A reader can verify their address from the mailed link and resend the nag | GET /verify/{token}, POST /verify/resend | venom: a reader without the membership reads for free and pays to write |
| A reader can sign out | POST /logout | venom: signing out expires the session |
| A reader can read the pricing and join a waitlist until payments open | GET /pricing, POST /pricing/waitlist | venom: a reader without the membership reads for free and pays to write; payments render their amounts and stay closed until stripe exists |
| A reader can open their money page; checkout opens once the deployment sets Stripe | GET /settings/payments, POST /settings/payments | venom: payments render their amounts and stay closed until stripe exists |
| A reader wears the badge their paid total earns | GET /settings/payments, GET /u/{username} | venom: a recorded payment grants and the badge appears |
| A reader can export their projects and articles any time | GET /export.zip | venom: the export is the signed-in account’s own |
| A reader reads for free and lands on /pricing on any attempt to write | the writing routes answer 402 | venom: a reader without the membership reads for free and pays to write |
Member
| Story | Routes | Proof |
|---|---|---|
| A member can create a project | GET /new, POST /new | venom: creating a public blog lands on it; project creation refuses a too-short slug and accepts a route name |
| A member can create a project over XHR with field-level refusals | POST /api/project | venom: the api creates a project and refuses a short slug with a field |
| A member can probe a slug before submitting | GET /api/slug-check | venom: the slug probe answers taken, short and available, signed in only; playwright: chrome.spec.ts “the new-project form probes slug availability live” |
| The slug fills in from the title until the member edits it by hand | GET /new | playwright: chrome.spec.ts “the slug fills in from the title until edited by hand” |
| A member can open the editor, with or without a project | GET /articles/edit | venom: the write route opens the editor without naming a project |
| A member can save a draft | POST /articles/edit | venom: a draft with markdown publishes and renders |
| A member can keep editing by id, surviving any rename | GET /articles/edit/{id}, POST /articles/edit/{id} | venom: an article moves to another project and takes its url with it |
| A member can save over XHR, and a draft exists from the first paste | POST /api/article | venom: the api creates a draft for the first paste to land in; a subset save keeps every absent field; the api saves an article and answers the listing redirect |
| A member sees a citation as the card the published page prints, while writing | POST /api/embed | venom: a citation answers the card the published page prints; playwright: editor.spec.ts “a citation draws the card the published page prints” |
| A member writes markdown on one surface, styled where it stands | GET /articles/edit/{id} | playwright: editor.spec.ts “the toolbar mounts over the body field and bold wraps the document”; “a heading typed as ### reads as a heading” |
| A member hides the markers and goes on editing what they marked | GET /articles/edit/{id} | playwright: editor.spec.ts “the pilcrow hides the markers and leaves the document whole” |
| A table in a member’s body draws as a table, edited cell by cell | GET /articles/edit/{id} | playwright: editor.spec.ts “a table in the document draws as a table and a cell writes back” |
| A member can paste an image or a script-free SVG, and upload refuses a scripted one | POST /api/upload | venom: an image pastes into the draft and uploads; an svg diagram uploads and the upload refuses a scripted svg; playwright: editor.spec.ts “a pasted image uploads and replaces its placeholder”; “the editor refuses a file this blog does not store” |
| A member lays a picture out left, right or centred, with no raw HTML | POST /api/upload, GET /u/{username}/{project}/{slug} | go: markdown TestRenderImageAlignment and storage TestReferencedUploadPaths keep the anchor; playwright: editor.spec.ts “a picture laid out with the left anchor floats, and the text wraps beside it” |
| A member can drop an image from a draft | POST /articles/{id}/images/{imageID}/delete | venom: an image pastes into the draft and uploads |
| A member can publish now or schedule for later | POST /articles/{id}/publish | venom: a draft with markdown publishes and renders; a scheduled article shares as a countdown teaser |
| A member can unpublish | POST /articles/{id}/unpublish | untested |
| A member can send an article to review | POST /articles/{id}/review | venom: a reviewer reads an article in review before anyone else |
| A member can trash, restore and purge their article, losslessly until the purge | POST /articles/{id}/delete, /articles/{id}/restore, /articles/{id}/purge | venom: a deleted article lands in the trash, restores, and purges |
| A member can work their drafts queues | GET /drafts, GET /p/{project}/drafts | venom: a project-less article has its own drafts, trash and fanout; a deleted article lands in the trash, restores, and purges |
| A member can empty their project-less trash | GET /trash, POST /trash/empty | venom: the project-less trash empties like a project’s |
| A member can publish under no project, and their followers hear | POST /articles/edit, POST /articles/{id}/publish | venom: a project-less article has its own drafts, trash and fanout |
| A member can move an article between projects, and the url follows | POST /articles/edit/{id} | venom: an article moves to another project and takes its url with it; a refused move commits nothing |
| A member can cross-list into projects they can write in, and nowhere else | POST /api/article | venom: the api saves an article and answers the listing redirect; a refused cross-listing leaves no half-made draft |
| A member can repost by embed, and the card is re-decided per reader | POST /articles/edit | venom: alice reposts her own article and a stranger reads the card; a repost of a private article is a placeholder for everyone else; a reading list renders one card per marker, in body order |
| A member can comment where the policy admits them | POST /articles/{id}/comment | venom: a comment needs the project’s audience, and a subscription grants it; an article closes its thread inside a project that allows comments |
| A member can edit their own comment, held or published | POST /articles/{id}/comment/{commentID}/edit | venom: an approve project holds a comment until its owner lets it through |
| A member can delete their own comment | POST /articles/{id}/comment/{commentID}/delete | untested |
| A member sets the comment floor for their project-less articles in /settings | POST /settings | venom: the comment defaults in /settings govern the project-less thread |
Project owner
| Story | Routes | Proof |
|---|---|---|
| An owner can configure the project, one setting at a time | GET /p/{project}/settings, POST /p/{project}/settings | venom: the retention window saves on its own, leaving the other settings |
| An owner can set visibility, and a private project vanishes for strangers | POST /p/{project}/settings | venom: a private journal does not exist for strangers |
| An owner sets the repost floor an article may lower, never raise | POST /p/{project}/settings | venom: the repost setting is a floor the project sets and an article lowers |
| An owner sets the comment floors an article may tighten | POST /p/{project}/settings | venom: an article closes its thread inside a project that allows comments |
| An owner can invite by mail with a role, and revoke an outstanding invitation | POST /p/{project}/settings | venom: an invitation binds to its address and its project |
| An owner can moderate the project’s held comments | GET /p/{project}/comments, POST /p/{project}/comments | venom: an approve project holds a comment until its owner lets it through |
| An owner sees every queue they own in one place | GET /comments | venom: an approve project holds a comment until its owner lets it through |
| An owner can act on a held comment from the combined queue | POST /comments | untested |
| An owner can restore from the project trash | GET /p/{project}/trash, POST /articles/{id}/restore | venom: a deleted article lands in the trash, restores, and purges |
| An owner can empty the project trash | POST /p/{project}/trash/empty | untested |
| An owner can read an article’s audit trail | GET /p/{project}/{article}/audit, GET /articles/{id}/audit | venom: a project-less article has its own drafts, trash and fanout (the id route) |
| An owner can delete the project; readers get 404 and the operator restores | POST /p/{project}/settings | venom: a deleted project comes back through the operator’s restore |
Co-writer
| Story | Routes | Proof |
|---|---|---|
| A co-writer can redeem an invitation mailed to their address, and no other | GET /invite/{token} | venom: an invitation binds to its address and its project |
| A co-writer can write, edit and publish their own work in the shared project | GET/POST /articles/edit, POST /articles/{id}/publish | untested |
Reviewer
| Story | Routes | Proof |
|---|---|---|
| A reviewer hears when an article enters review, again on re-entry | GET /notifications | venom: a reviewer reads an article in review before anyone else |
| A reviewer can read a review article before publication | GET /u/{username}/{project}/{article} | venom: a reviewer reads an article in review before anyone else |
| A reviewer can post feedback as a review-flagged comment while the state holds | POST /articles/{id}/comment | venom: a reviewer reads an article in review before anyone else |
Subscriber / follower
| Story | Routes | Proof |
|---|---|---|
| A follower gets one notification when a followed author publishes, across every listed project | GET /notifications | venom: a second account follows, subscribes, and gets one notification |
| A follower hears when an author they follow publishes a project-less article | GET /feed, GET /notifications | venom: a project-less article has its own drafts, trash and fanout |
| A follower’s followed tags list public articles in /feed, and no others | GET /feed | venom: a followed tag lists public articles in the feed, and no others |
| A subscriber counts as the audience where a comment policy says followers | POST /articles/{id}/comment | venom: a comment needs the project’s audience, and a subscription grants it |
| A follower can read a followers-only reading list | GET /u/{username}/bookmarks.xml | venom: the bookmark feed honours its three visibility settings |
Operator
| Story | Routes / commands | Proof |
|---|---|---|
| The operator can grant a membership by hand | blog member grant | venom: registration signs the browser in |
| The operator can read the waitlists, with verification state | blog member waitlist | venom: a reader without the membership reads for free and pays to write |
| The operator can revoke a membership as a sanction a waitlist click cannot undo | blog member revoke | go: cmd/blog/member |
| The operator can read an account’s full audit trail | blog user audit | venom: a rename in /settings moves the page and the old address 404s |
| The operator can read the email trail and restore a verifiably held address | blog user emails, blog user restore-email | venom: the audit log records an email change and the operator can restore it |
| The operator can suspend and unsuspend, and the sign-in stays generic | blog user suspend, blog user unsuspend | venom: the operator suspends an account and the door stays generic |
| The operator can purge one article outright, live or trashed | blog article purge | go: cmd/blog/article |
| The operator can restore a deleted project, its articles waiting in its trash | blog project restore | venom: a deleted project comes back through the operator’s restore |
| The operator can record money taken outside Stripe and list an account’s history | blog payment record, blog payment list | venom: a recorded payment grants and the badge appears |
| The operator can snapshot and restore the deployment | blog backup, blog restore | go: cmd/blog/backup, cmd/blog/restore |
| The operator can load an export back in as fresh rows under one account | blog import | go: cmd/blog/importer |
| The operator lets Stripe settle checkouts, renewals and cancellations | POST /stripe/webhook | venom: payments render their amounts and stay closed until stripe exists; go: service/payments |
Unclaimed routes: none - every @route in frontend/modules/ and every Go route (/feed.xml, /u/{username}/feed.xml, /u/{username}/bookmarks.xml, /p/{project}/feed.xml, /export.zip, POST /stripe/webhook) has a story above that claims it.
Where the data lives and surfaces
| Table | One row is | Surfaces |
|---|---|---|
| article | one article: metadata, state, counters | /u/{username}/{project}/{article}, /u/{username}/{slug}, every listing and Atom feed, /export.zip |
| article_bookmark | one reader bookmarked one article | /u/{username}/bookmarks and its .xml twin; the bookmark count on the article page |
| article_content | the markdown of one article | the article page, the editor, /export.zip |
| article_embed | one article cited in another’s body | the preview card rendered where the marker sits |
| article_image | one image referenced by one article | the editor’s image list and its delete button; the reference count the orphan sweep reads |
| article_like | one reader liked one article | the like count on the article page; the /feed likes floor |
| article_project | one article listed in one project, home included | the project page, /search?project=, the projects: front matter in /export.zip |
| article_revision | one full snapshot of an article at one save | /p/{project}/{article}/audit for a project article, /articles/{id}/audit for a project-less one; the controller only - a project owner, or the project-less article’s author |
| article_tag | one tag on one article | the article page’s tag line, /t/{tag}, the tags: front matter in /export.zip |
| comment | one comment: state, review flag | the article thread, /p/{project}/comments, /comments |
| comment_body | the text of one comment | the same thread and queues |
| email_verification | one mailed verification link, token hashed | internal, ephemeral by design; consumed by GET /verify/{token} |
| follow | one reader follows one user | /settings/following; the follower count on the profile |
| image | one uploaded file | /upload/{path} wherever an article references it; uploads/ in /export.zip |
| login_token | one mailed sign-in link, token hashed | internal, ephemeral by design; consumed by GET /login/link/{token} |
| membership_waitlist | one account on a plan list | CLI: blog member waitlist; the /pricing button state |
| notification | one event addressed to one recipient | /notifications; the unread bubble on the account disc |
| payment | one charge: pending, paid or failed | /settings/payments; CLI: blog payment list |
| project | one project | /u/{owner}/{slug}, /u/{username}, /export.zip |
| project_invitation | one outstanding invitation, token hashed | the owner’s list on /p/{project}/settings; redeemed at /invite/{token} |
| project_member | one person’s role on one project | the members list on /p/{project}/settings |
| project_setting | one project’s configuration | /p/{project}/settings |
| project_subscription | one reader subscribes to one project | /settings/following; the audience checks on comments and feeds |
| tag | one word in the site-wide vocabulary | /t/{tag} |
| tag_follow | one reader follows one tag | /settings/following; /feed |
| user | one account | /u/{username}, /settings |
| user_audit | one account field change, append-only | CLI only, with no page: blog user audit, blog user emails |
| user_session | one browser session, or an anonymous one holding a CSRF token | internal, ephemeral by design; cleared by sign-out everywhere, suspension and deletion |
mig creates and owns migrations; it holds no domain data.