diff --git a/README.md b/README.md
index 9d37740daab811e066e752d29c4e78d85f80b94b..2b2c46c242588793f9d691f6766e4f9df405c8cb 100644
--- a/README.md
+++ b/README.md
@@ -306,3 +306,17 @@
 recovery, feed parsing, and sequence pagination. Read the
 [repository event service architectural decision record](docs/adr/0016-repository-event-service.md)
 for the event type and payload contracts.
+
+## Milestone 4.2 gate
+
+Run the issue workflow gate:
+
+```text
+./scripts/check-m4-2
+```
+
+This command tests issue numbers, raw Markdown storage, safe rendering, roles,
+comments, state, labels, assignees, the event timeline, transaction rollback,
+sessions, CSRF checks, and forms that operate without JavaScript. Read the
+[issue workflow architectural decision record](docs/adr/0017-issue-workflow.md)
+for the permission and event contracts.
diff --git a/assets/style.css b/assets/style.css
index 7763f704b3767ec1ad8c039eccf8daa760569c35..a914c92a65b05d4936447a6ee082419cb7a8154b 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -103,6 +103,7 @@
 }
 
 input,
+textarea,
 select,
 button {
   max-width: 100%;
@@ -116,6 +117,11 @@
 
 input {
   width: 100%;
+}
+
+textarea {
+  width: 100%;
+  resize: vertical;
 }
 
 button {
diff --git a/docs/adr/0016-repository-event-service.md b/docs/adr/0016-repository-event-service.md
index b450b606f6d0624633792add080af855a1b41740..d17a00a4512bbbe72e811833dde23b3cd459e6d2 100644
--- a/docs/adr/0016-repository-event-service.md
+++ b/docs/adr/0016-repository-event-service.md
@@ -32,6 +32,9 @@
 `repository-imported`, `push`, `ref-created`, `ref-updated`, `ref-deleted`,
 `tag-created`, `tag-updated`, and `tag-deleted`.
 
+Architectural decision record 0017 adds the version 1 issue event types to this
+service.
+
 Store `payload_version` as an explicit schema value and store `version` in each
 JSON payload. The schema accepts only a JSON object whose inner version equals
 the column value. Limit a payload to 1 MiB. Version 1 repository payloads have
diff --git a/docs/adr/0017-issue-workflow.md b/docs/adr/0017-issue-workflow.md
new file mode 100644
index 0000000000000000000000000000000000000000..840a5bc5aef22d218cb7500cbcf77aa83a093e39
--- /dev/null
+++ b/docs/adr/0017-issue-workflow.md
@@ -1,0 +1,88 @@
+# Architectural decision record 0017: Issue workflow
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+An issue needs a stable repository number, a Markdown source, comments, labels,
+assignees, state changes, and one chronological timeline. An issue mutation and
+its event must not have different results.
+
+The repository roles do not have a separate triage role. The issue workflow
+must use the existing roles and must not add a second permission system.
+
+## Decision
+
+Store issues and comments in SQLite. Give each issue and comment a random,
+non-reassignable ID. Allocate an increasing issue number from one repository
+counter. Use the issue number in the public URL:
+
+```text
+/OWNER/REPOSITORY/issues/NUMBER
+```
+
+Store the exact Markdown source from the request. Render the supported subset
+only when the server makes the HTML page. Limit an issue title to 200 bytes.
+Limit an issue body or comment to 256 KiB. Reject control characters except
+tab and line terminators in Markdown bodies.
+
+Use these permissions:
+
+- An authenticated account that can read the repository can create an issue
+  and add a comment.
+- The issue author, an owner, a maintainer, or a writer can edit, close, or
+  reopen the issue.
+- An owner or maintainer can add or remove labels and assignees.
+- An assignee must be active and must be able to read the repository.
+
+Run the permission query, metadata mutation, and event insert in one immediate
+SQLite transaction. Use the repository event sequence as the issue timeline
+order. Do not add a separate timeline table.
+
+Add these version 1 event types: `issue-created`, `issue-edited`,
+`issue-commented`, `issue-closed`, `issue-reopened`, `issue-labeled`,
+`issue-unlabeled`, `issue-assigned`, and `issue-unassigned`. Each event payload
+has the issue ID and issue number. An event also has the data that identifies
+its change, such as the comment ID, label, assignee, state, title, or body.
+
+Keep labels in one repository. Compare label names without ASCII case
+differences. Keep a label record after its last removal so a subsequent use has
+the same identity.
+
+The Web interface uses server-rendered pages and normal HTML forms. Each
+mutation requires an active session and a matching CSRF value. Private issue
+reads use the same repository read rule as Git and repository pages.
+
+## Failure and threat cases
+
+An invalid repository, issue number, title, body, label, state, or assignee does
+not change the database. A suspended account cannot mutate an issue. An
+unauthorized account cannot learn whether an issue exists in a private
+repository from a read response.
+
+A database constraint rejects a repeated issue number, an invalid state, an
+invalid ID, an invalid label relation, or an event without its issue. If event
+insertion fails, SQLite rolls back the issue mutation. The event payload limit
+is larger than the permitted Markdown source after JSON escaping of accepted
+characters.
+
+## Evidence
+
+Storage tests run the complete workflow with reader, writer, maintainer, owner,
+stranger, and suspended-account boundaries. They verify increasing numbers,
+exact Markdown source, labels, assignees, comments, state, event payloads, and
+timeline order. An injected event failure proves that a comment and its event
+roll back together.
+
+A production HTTP test creates, reads, edits, comments on, labels, assigns,
+closes, and reopens an issue. It also verifies CSRF rejection, safe Markdown
+rendering, the no-JavaScript form flow, and the repository Atom projection.
+
+## Consequences
+
+Issue pages and later SSH commands can use one issue service. Watches and
+private feeds can select issue events from the repository event stream. A
+subsequent triage role requires an explicit change to the common permission
+contract.
diff --git a/scripts/check-m4-2 b/scripts/check-m4-2
new file mode 100755
index 0000000000000000000000000000000000000000..1e5e6d6b38e5830f0fb4ed5102262e8258ceafa0
--- /dev/null
+++ b/scripts/check-m4-2
@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles
+cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
diff --git a/src/feed.rs b/src/feed.rs
index cb163878fc69057efdd8595aa2ca179cd1e7a81c..561ba501b4e0c225f2d8ea6d1d4f71cc73b98f87 100644
--- a/src/feed.rs
+++ b/src/feed.rs
@@ -143,8 +143,47 @@
         "tag-created" => format!("Tag {reference} created"),
         "tag-updated" => format!("Tag {reference} updated"),
         "tag-deleted" => format!("Tag {reference} deleted"),
+        "issue-created" => issue_title(event, "opened"),
+        "issue-edited" => issue_title(event, "edited"),
+        "issue-commented" => issue_title(event, "commented on"),
+        "issue-closed" => issue_title(event, "closed"),
+        "issue-reopened" => issue_title(event, "reopened"),
+        "issue-labeled" => issue_value_title(event, "added label", "label"),
+        "issue-unlabeled" => issue_value_title(event, "removed label", "label"),
+        "issue-assigned" => issue_value_title(event, "assigned", "assignee"),
+        "issue-unassigned" => issue_value_title(event, "unassigned", "assignee"),
         _ => "Repository event".to_owned(),
     }
+}
+
+fn issue_title(event: &RepositoryEventRecord, action: &str) -> String {
+    let number = issue_payload(event)
+        .and_then(|payload| payload.get("number")?.as_i64())
+        .map(|number| format!("#{number}"))
+        .unwrap_or_else(|| "Issue".to_owned());
+    format!("{} {action} {number}", event.actor)
+}
+
+fn issue_value_title(event: &RepositoryEventRecord, action: &str, field: &str) -> String {
+    let Some(payload) = issue_payload(event) else {
+        return issue_title(event, action);
+    };
+    let number = payload
+        .get("number")
+        .and_then(serde_json::Value::as_i64)
+        .map(|number| format!("#{number}"))
+        .unwrap_or_else(|| "issue".to_owned());
+    let value = payload
+        .get(field)
+        .and_then(serde_json::Value::as_str)
+        .unwrap_or("unknown");
+    format!("{} {action} {value} on {number}", event.actor)
+}
+
+fn issue_payload(event: &RepositoryEventRecord) -> Option<serde_json::Value> {
+    (event.payload_version == 1)
+        .then(|| serde_json::from_str(&event.payload).ok())
+        .flatten()
 }
 
 fn event_description(event: &RepositoryEventRecord) -> String {
diff --git a/src/http/issues.rs b/src/http/issues.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2cdf1b1a33e3d5d6af6e1a69a1a9426d7a8581e1
--- /dev/null
+++ b/src/http/issues.rs
@@ -1,0 +1,591 @@
+use askama::Template;
+use axum::Router;
+use axum::body::Bytes;
+use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use serde::Deserialize;
+
+use crate::issue::{IssueError, IssueService};
+use crate::markdown::{self, RenderedMarkdown};
+use crate::store::{IssueDetail, StoreError};
+
+use super::{
+    CSRF_COOKIE, RequestActor, RequestId, SESSION_COOKIE, WebState, cookie, login_job,
+    login_redirect, parse_named_form, render, render_error,
+};
+
+const MAX_ISSUE_REQUEST_BYTES: usize = 300 * 1024;
+
+pub(super) fn routes() -> Router<WebState> {
+    Router::new()
+        .route(
+            "/{owner}/{repository}/issues",
+            get(issue_list).post(create_issue),
+        )
+        .route("/{owner}/{repository}/issues/{number}", get(issue_detail))
+        .route(
+            "/{owner}/{repository}/issues/{number}/edit",
+            post(edit_issue),
+        )
+        .route(
+            "/{owner}/{repository}/issues/{number}/comments",
+            post(comment_issue),
+        )
+        .route(
+            "/{owner}/{repository}/issues/{number}/state",
+            post(change_state),
+        )
+        .route(
+            "/{owner}/{repository}/issues/{number}/labels",
+            post(change_label),
+        )
+        .route(
+            "/{owner}/{repository}/issues/{number}/assignees",
+            post(change_assignee),
+        )
+        .layer(DefaultBodyLimit::max(MAX_ISSUE_REQUEST_BYTES))
+}
+
+async fn issue_list(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(service) = state.issues.clone() else {
+        return issue_read_error(
+            IssueError::Store(StoreError::Integrity(
+                "issue service is unavailable".to_owned(),
+            )),
+            &request_id.0,
+        );
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let authenticated = actor.0.is_some();
+    let result = issue_job(state, move || {
+        service.list(&owner, &repository, actor.0.as_deref())
+    })
+    .await;
+    match result {
+        Ok((record, issues)) => {
+            let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
+            render(
+                StatusCode::OK,
+                &IssueListTemplate {
+                    request_id: &request_id.0,
+                    owner: &record.owner,
+                    repository: &record.slug,
+                    issues: issues
+                        .iter()
+                        .map(|issue| IssueListItem {
+                            number: issue.number,
+                            title: &issue.title,
+                            state: &issue.state,
+                            author: &issue.author,
+                            updated_at: issue.updated_at,
+                        })
+                        .collect(),
+                    csrf: &csrf,
+                    can_create: authenticated && !csrf.is_empty(),
+                },
+            )
+        }
+        Err(error) => issue_read_error(error, &request_id.0),
+    }
+}
+
+async fn issue_detail(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(service) = state.issues.clone() else {
+        return issue_read_error(
+            IssueError::Store(StoreError::Integrity(
+                "issue service is unavailable".to_owned(),
+            )),
+            &request_id.0,
+        );
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = issue_job(state, move || {
+        service.get(&owner, &repository, number, actor.0.as_deref())
+    })
+    .await;
+    match result {
+        Ok(detail) => render_issue(&request_id.0, &headers, &detail),
+        Err(error) => issue_read_error(error, &request_id.0),
+    }
+}
+
+async fn create_issue(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.issues.clone() else {
+        return issue_internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = issue_job(state, move || {
+        service.create(&owner, &repository, &actor, &fields[1], &fields[2])
+    })
+    .await;
+    match result {
+        Ok(issue) => issue_redirect(&path.owner, &path.repository, issue.number),
+        Err(error) => issue_mutation_error(error, &request_id.0),
+    }
+}
+
+async fn edit_issue(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    mutate(state, request_id, path, move |service, path| {
+        service.edit(
+            &path.owner,
+            &path.repository,
+            path.number,
+            &actor,
+            &fields[1],
+            &fields[2],
+        )
+    })
+    .await
+}
+
+async fn comment_issue(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "body"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    mutate(state, request_id, path, move |service, path| {
+        service
+            .comment(
+                &path.owner,
+                &path.repository,
+                path.number,
+                &actor,
+                &fields[1],
+            )
+            .map(|_| ())
+    })
+    .await
+}
+
+async fn change_state(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    mutate(state, request_id, path, move |service, path| {
+        service.set_state(
+            &path.owner,
+            &path.repository,
+            path.number,
+            &actor,
+            &fields[1],
+        )
+    })
+    .await
+}
+
+async fn change_label(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "label", "operation"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let present = match operation(&fields[2]) {
+        Ok(present) => present,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    mutate(state, request_id, path, move |service, path| {
+        service.set_label(
+            &path.owner,
+            &path.repository,
+            path.number,
+            &actor,
+            &fields[1],
+            present,
+        )
+    })
+    .await
+}
+
+async fn change_assignee(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<IssuePath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "assignee", "operation"]) {
+        Ok(fields) => fields,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let present = match operation(&fields[2]) {
+        Ok(present) => present,
+        Err(()) => return issue_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    mutate(state, request_id, path, move |service, path| {
+        service.set_assignee(
+            &path.owner,
+            &path.repository,
+            path.number,
+            &actor,
+            &fields[1],
+            present,
+        )
+    })
+    .await
+}
+
+async fn mutate(
+    state: WebState,
+    request_id: RequestId,
+    path: IssuePath,
+    operation: impl FnOnce(IssueService, &IssuePath) -> Result<(), IssueError> + Send + 'static,
+) -> Response {
+    let Some(service) = state.issues.clone() else {
+        return issue_internal(&request_id.0);
+    };
+    let redirect_owner = path.owner.clone();
+    let redirect_repository = path.repository.clone();
+    let redirect_number = path.number;
+    let result = issue_job(state, move || operation(service, &path)).await;
+    match result {
+        Ok(()) => issue_redirect(&redirect_owner, &redirect_repository, redirect_number),
+        Err(error) => issue_mutation_error(error, &request_id.0),
+    }
+}
+
+async fn authenticate_mutation(
+    state: WebState,
+    headers: &HeaderMap,
+    submitted_csrf: &str,
+    request_id: &str,
+) -> Result<String, Response> {
+    let Some(session_token) = cookie(headers, SESSION_COOKIE) else {
+        return Err(login_redirect(false));
+    };
+    let Some(csrf) = cookie(headers, CSRF_COOKIE) else {
+        return Err(login_redirect(true));
+    };
+    if submitted_csrf != csrf {
+        return Err(render_error(
+            StatusCode::FORBIDDEN,
+            request_id,
+            "Forbidden",
+            "The request is not authorized.",
+        ));
+    }
+    login_job(state, move |login| {
+        login.authenticate(&session_token, Some(&csrf))
+    })
+    .await
+    .map(|session| session.username)
+    .map_err(|_| login_redirect(true))
+}
+
+async fn issue_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce() -> Result<T, IssueError> + Send + 'static,
+) -> Result<T, IssueError> {
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        IssueError::Store(StoreError::Integrity(
+            "issue worker pool is unavailable".to_owned(),
+        ))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation()
+    })
+    .await
+    .map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
+}
+
+fn operation(value: &str) -> Result<bool, ()> {
+    match value {
+        "add" => Ok(true),
+        "remove" => Ok(false),
+        _ => Err(()),
+    }
+}
+
+fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
+    let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
+    render(
+        StatusCode::OK,
+        &IssueTemplate {
+            request_id,
+            owner: &detail.repository.owner,
+            repository: &detail.repository.slug,
+            number: detail.issue.number,
+            title: &detail.issue.title,
+            body: &detail.issue.body,
+            body_html: markdown::render(&detail.issue.body),
+            state: &detail.issue.state,
+            author: &detail.issue.author,
+            created_at: detail.issue.created_at,
+            updated_at: detail.issue.updated_at,
+            labels: &detail.labels,
+            assignees: &detail.assignees,
+            comments: detail
+                .comments
+                .iter()
+                .map(|comment| CommentView {
+                    id: &comment.id,
+                    author: &comment.author,
+                    body_html: markdown::render(&comment.body),
+                    created_at: comment.created_at,
+                })
+                .collect(),
+            timeline: detail
+                .timeline
+                .iter()
+                .map(|event| TimelineView {
+                    sequence: event.sequence,
+                    kind: &event.kind,
+                    actor: &event.actor,
+                    created_at: event.created_at,
+                })
+                .collect(),
+            csrf: &csrf,
+            can_comment: detail.can_comment && !csrf.is_empty(),
+            can_edit: detail.can_edit && !csrf.is_empty(),
+            can_maintain: detail.can_maintain && !csrf.is_empty(),
+            is_open: detail.issue.state == "open",
+        },
+    )
+}
+
+fn issue_read_error(error: IssueError, request_id: &str) -> Response {
+    match error {
+        IssueError::Store(
+            StoreError::RepositoryNotFound(_, _)
+            | StoreError::IssueNotFound(_, _, _)
+            | StoreError::IssueDenied
+            | StoreError::IssueHidden,
+        )
+        | IssueError::Auth(_)
+        | IssueError::RepositoryName(_)
+        | IssueError::Number => render_error(
+            StatusCode::NOT_FOUND,
+            request_id,
+            "Not found",
+            "The issue was not found.",
+        ),
+        _ => issue_internal(request_id),
+    }
+}
+
+fn issue_mutation_error(error: IssueError, request_id: &str) -> Response {
+    match error {
+        IssueError::Auth(_)
+        | IssueError::RepositoryName(_)
+        | IssueError::Number
+        | IssueError::Title
+        | IssueError::Body
+        | IssueError::State
+        | IssueError::Label => issue_bad_request(request_id),
+        IssueError::Store(StoreError::IssueDenied) => render_error(
+            StatusCode::FORBIDDEN,
+            request_id,
+            "Forbidden",
+            "The issue change is not authorized.",
+        ),
+        IssueError::Store(
+            StoreError::IssueState(_)
+            | StoreError::IssueLabelState
+            | StoreError::IssueAssigneeState,
+        ) => render_error(
+            StatusCode::CONFLICT,
+            request_id,
+            "Issue conflict",
+            "The issue already has the requested state.",
+        ),
+        IssueError::Store(
+            StoreError::RepositoryNotFound(_, _)
+            | StoreError::IssueNotFound(_, _, _)
+            | StoreError::IssueAssigneeNotFound(_)
+            | StoreError::IssueHidden,
+        ) => render_error(
+            StatusCode::NOT_FOUND,
+            request_id,
+            "Not found",
+            "The issue or account was not found.",
+        ),
+        _ => issue_internal(request_id),
+    }
+}
+
+fn issue_bad_request(request_id: &str) -> Response {
+    render_error(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "Issue error",
+        "The issue request is not valid.",
+    )
+}
+
+fn issue_internal(request_id: &str) -> Response {
+    render_error(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        request_id,
+        "Issue error",
+        "The issue request could not be completed.",
+    )
+}
+
+fn issue_redirect(owner: &str, repository: &str, number: i64) -> Response {
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(
+            header::LOCATION,
+            format!("/{owner}/{repository}/issues/{number}"),
+        )
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(axum::body::Body::empty())
+        .expect("the issue redirect is valid")
+}
+
+#[derive(Clone, Deserialize)]
+struct RepositoryPath {
+    owner: String,
+    repository: String,
+}
+
+#[derive(Clone, Deserialize)]
+struct IssuePath {
+    owner: String,
+    repository: String,
+    number: i64,
+}
+
+#[derive(Template)]
+#[template(path = "issues.html")]
+struct IssueListTemplate<'a> {
+    request_id: &'a str,
+    owner: &'a str,
+    repository: &'a str,
+    issues: Vec<IssueListItem<'a>>,
+    csrf: &'a str,
+    can_create: bool,
+}
+
+struct IssueListItem<'a> {
+    number: i64,
+    title: &'a str,
+    state: &'a str,
+    author: &'a str,
+    updated_at: i64,
+}
+
+#[derive(Template)]
+#[template(path = "issue.html")]
+struct IssueTemplate<'a> {
+    request_id: &'a str,
+    owner: &'a str,
+    repository: &'a str,
+    number: i64,
+    title: &'a str,
+    body: &'a str,
+    body_html: RenderedMarkdown,
+    state: &'a str,
+    author: &'a str,
+    created_at: i64,
+    updated_at: i64,
+    labels: &'a [String],
+    assignees: &'a [String],
+    comments: Vec<CommentView<'a>>,
+    timeline: Vec<TimelineView<'a>>,
+    csrf: &'a str,
+    can_comment: bool,
+    can_edit: bool,
+    can_maintain: bool,
+    is_open: bool,
+}
+
+struct CommentView<'a> {
+    id: &'a str,
+    author: &'a str,
+    body_html: RenderedMarkdown,
+    created_at: i64,
+}
+
+struct TimelineView<'a> {
+    sequence: i64,
+    kind: &'a str,
+    actor: &'a str,
+    created_at: i64,
+}
diff --git a/src/http/mod.rs b/src/http/mod.rs
index a2e3d8caea9b521a50c93fd74b0813d6f6707fd1..327ccf15f8de813f0bf1d133f890ea4514dc9089 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -1,3 +1,4 @@
+mod issues;
 mod public;
 
 use std::net::SocketAddr;
@@ -20,6 +21,7 @@
 use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::issue::IssueService;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::session::{SessionError, WebLoginService};
 use crate::store::StoreError;
@@ -42,6 +44,7 @@
     key_reloader: Option<AccountKeyReloader>,
     login: Option<WebLoginService>,
     repositories: Option<RepositoryService>,
+    issues: Option<IssueService>,
     secure_cookies: bool,
 }
 
@@ -74,6 +77,7 @@
                 key_reloader: None,
                 login: None,
                 repositories: None,
+                issues: None,
                 secure_cookies: false,
             },
         )
@@ -109,6 +113,7 @@
         let login = WebLoginService::new(database, &public_url)?;
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         let repositories = RepositoryService::new(public.database(), public.repository_root());
+        let issues = IssueService::new(public.database());
         Self::start_with_state(
             address,
             WebState {
@@ -118,6 +123,7 @@
                 key_reloader,
                 login: Some(login),
                 repositories: Some(repositories),
+                issues: Some(issues),
                 secure_cookies,
             },
         )
@@ -161,15 +167,19 @@
         key_reloader: None,
         login: None,
         repositories: None,
+        issues: None,
         secure_cookies: false,
     })
 }
 
 fn router_with_state(state: WebState) -> Router {
-    let repository_routes = public::routes().layer(middleware::from_fn_with_state(
-        state.clone(),
-        repository_actor,
-    ));
+    let repository_routes =
+        issues::routes()
+            .merge(public::routes())
+            .layer(middleware::from_fn_with_state(
+                state.clone(),
+                repository_actor,
+            ));
     Router::new()
         .route("/", get(home))
         .route("/go", get(go_to_repository))
diff --git a/src/issue.rs b/src/issue.rs
new file mode 100644
index 0000000000000000000000000000000000000000..761ea5ff0bdf1bf6bc3a35d134e1a1b11721c32f
--- /dev/null
+++ b/src/issue.rs
@@ -1,0 +1,273 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::store::{
+    IssueChange, IssueDetail, IssueRecord, NewIssue, RepositoryRecord, Store, StoreError,
+};
+
+const MAX_TITLE_BYTES: usize = 200;
+const MAX_BODY_BYTES: usize = 256 * 1024;
+const MAX_LABEL_BYTES: usize = 80;
+
+#[derive(Clone)]
+pub(crate) struct IssueService {
+    database: PathBuf,
+}
+
+impl IssueService {
+    pub(crate) fn new(database: &Path) -> Self {
+        Self {
+            database: database.to_owned(),
+        }
+    }
+
+    pub(crate) fn create(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+        title: &str,
+        body: &str,
+    ) -> Result<IssueRecord, IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_title(title)?;
+        validate_body(body, true)?;
+        let mut store = Store::open(&self.database)?;
+        store
+            .create_issue(&NewIssue {
+                owner,
+                repository,
+                actor,
+                title,
+                body,
+                created_at: timestamp()?,
+            })
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn list(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+    ) -> Result<(RepositoryRecord, Vec<IssueRecord>), IssueError> {
+        validate_context(owner, repository, actor)?;
+        Store::open(&self.database)?
+            .issues(owner, repository, actor)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn get(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+    ) -> Result<IssueDetail, IssueError> {
+        validate_context(owner, repository, actor)?;
+        validate_number(number)?;
+        Store::open(&self.database)?
+            .issue_detail(owner, repository, number, actor)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn edit(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        title: &str,
+        body: &str,
+    ) -> Result<(), IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_number(number)?;
+        validate_title(title)?;
+        validate_body(body, true)?;
+        Store::open(&self.database)?
+            .edit_issue(
+                &IssueChange {
+                    owner,
+                    repository,
+                    number,
+                    actor,
+                    changed_at: timestamp()?,
+                },
+                title,
+                body,
+            )
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn comment(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        body: &str,
+    ) -> Result<String, IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_number(number)?;
+        validate_body(body, false)?;
+        Store::open(&self.database)?
+            .comment_issue(owner, repository, number, actor, body, timestamp()?)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn set_state(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        state: &str,
+    ) -> Result<(), IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_number(number)?;
+        if !matches!(state, "open" | "closed") {
+            return Err(IssueError::State);
+        }
+        Store::open(&self.database)?
+            .set_issue_state(owner, repository, number, actor, state, timestamp()?)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn set_label(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        label: &str,
+        present: bool,
+    ) -> Result<(), IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_number(number)?;
+        validate_label(label)?;
+        Store::open(&self.database)?
+            .set_issue_label(
+                &IssueChange {
+                    owner,
+                    repository,
+                    number,
+                    actor,
+                    changed_at: timestamp()?,
+                },
+                label,
+                present,
+            )
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn set_assignee(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        assignee: &str,
+        present: bool,
+    ) -> Result<(), IssueError> {
+        validate_context(owner, repository, Some(actor))?;
+        validate_number(number)?;
+        validate_username(assignee)?;
+        Store::open(&self.database)?
+            .set_issue_assignee(
+                &IssueChange {
+                    owner,
+                    repository,
+                    number,
+                    actor,
+                    changed_at: timestamp()?,
+                },
+                assignee,
+                present,
+            )
+            .map_err(Into::into)
+    }
+}
+
+fn validate_context(owner: &str, repository: &str, actor: Option<&str>) -> Result<(), IssueError> {
+    validate_username(owner)?;
+    validate_slug(repository)?;
+    if let Some(actor) = actor {
+        validate_username(actor)?;
+    }
+    Ok(())
+}
+
+fn validate_number(number: i64) -> Result<(), IssueError> {
+    if number < 1 {
+        return Err(IssueError::Number);
+    }
+    Ok(())
+}
+
+fn validate_title(title: &str) -> Result<(), IssueError> {
+    if title.is_empty()
+        || title.len() > MAX_TITLE_BYTES
+        || title.trim() != title
+        || title.chars().any(char::is_control)
+    {
+        return Err(IssueError::Title);
+    }
+    Ok(())
+}
+
+fn validate_body(body: &str, empty_ok: bool) -> Result<(), IssueError> {
+    if body.len() > MAX_BODY_BYTES
+        || (!empty_ok && body.trim().is_empty())
+        || body
+            .chars()
+            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
+    {
+        return Err(IssueError::Body);
+    }
+    Ok(())
+}
+
+fn validate_label(label: &str) -> Result<(), IssueError> {
+    if label.is_empty()
+        || label.len() > MAX_LABEL_BYTES
+        || label.trim() != label
+        || label.chars().any(char::is_control)
+    {
+        return Err(IssueError::Label);
+    }
+    Ok(())
+}
+
+fn timestamp() -> Result<i64, IssueError> {
+    let seconds = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| IssueError::Clock)?
+        .as_secs();
+    i64::try_from(seconds).map_err(|_| IssueError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum IssueError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    RepositoryName(#[from] RepositoryNameError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("issue number is not valid")]
+    Number,
+    #[error("issue title is not valid")]
+    Title,
+    #[error("issue body is not valid")]
+    Body,
+    #[error("issue state is not valid")]
+    State,
+    #[error("issue label is not valid")]
+    Label,
+    #[error("system time is not valid")]
+    Clock,
+}
diff --git a/src/main.rs b/src/main.rs
index 0891b158adb8f44f14822d9740bcb70524547c84..e5632abc59ed48f0afdd5724d2199fdf212311b1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16,6 +16,7 @@
 #[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
 mod http;
 mod instance;
+mod issue;
 mod markdown;
 mod policy;
 mod repository;
diff --git a/src/store/event.rs b/src/store/event.rs
index 001aea2a97b611dc84c551e9fd6f7b9dbe05140b..dfec264efd1ed353af144e6c6ee3078f696dac91 100644
--- a/src/store/event.rs
+++ b/src/store/event.rs
@@ -13,6 +13,15 @@
     TagCreated,
     TagUpdated,
     TagDeleted,
+    IssueCreated,
+    IssueEdited,
+    IssueCommented,
+    IssueClosed,
+    IssueReopened,
+    IssueLabeled,
+    IssueUnlabeled,
+    IssueAssigned,
+    IssueUnassigned,
 }
 
 impl EventKind {
@@ -27,6 +36,15 @@
             Self::TagCreated => "tag-created",
             Self::TagUpdated => "tag-updated",
             Self::TagDeleted => "tag-deleted",
+            Self::IssueCreated => "issue-created",
+            Self::IssueEdited => "issue-edited",
+            Self::IssueCommented => "issue-commented",
+            Self::IssueClosed => "issue-closed",
+            Self::IssueReopened => "issue-reopened",
+            Self::IssueLabeled => "issue-labeled",
+            Self::IssueUnlabeled => "issue-unlabeled",
+            Self::IssueAssigned => "issue-assigned",
+            Self::IssueUnassigned => "issue-unassigned",
         }
     }
 }
@@ -91,6 +109,119 @@
             "name_hex": encode_hex(name),
             "old_target": old_target,
             "new_target": new_target,
+        })
+        .to_string(),
+    }
+}
+
+pub(super) fn issue(
+    kind: EventKind,
+    issue_id: &str,
+    number: i64,
+    title: &str,
+    body: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::IssueCreated | EventKind::IssueEdited
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "issue_id": issue_id,
+            "number": number,
+            "title": title,
+            "body": body,
+        })
+        .to_string(),
+    }
+}
+
+pub(super) fn issue_comment(
+    issue_id: &str,
+    number: i64,
+    comment_id: &str,
+    author: &str,
+    body: &str,
+) -> VersionedEvent {
+    VersionedEvent {
+        kind: EventKind::IssueCommented,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "issue_id": issue_id,
+            "number": number,
+            "comment_id": comment_id,
+            "author": author,
+            "body": body,
+        })
+        .to_string(),
+    }
+}
+
+pub(super) fn issue_state(
+    kind: EventKind,
+    issue_id: &str,
+    number: i64,
+    state: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::IssueClosed | EventKind::IssueReopened
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "issue_id": issue_id,
+            "number": number,
+            "state": state,
+        })
+        .to_string(),
+    }
+}
+
+pub(super) fn issue_label(
+    kind: EventKind,
+    issue_id: &str,
+    number: i64,
+    label_id: &str,
+    label: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::IssueLabeled | EventKind::IssueUnlabeled
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "issue_id": issue_id,
+            "number": number,
+            "label_id": label_id,
+            "label": label,
+        })
+        .to_string(),
+    }
+}
+
+pub(super) fn issue_assignee(
+    kind: EventKind,
+    issue_id: &str,
+    number: i64,
+    assignee: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::IssueAssigned | EventKind::IssueUnassigned
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "issue_id": issue_id,
+            "number": number,
+            "assignee": assignee,
         })
         .to_string(),
     }
diff --git a/src/store/migrations/012_issues.sql b/src/store/migrations/012_issues.sql
new file mode 100644
index 0000000000000000000000000000000000000000..3949ce7ba4df4697f7883dfb64a7ab85a6646437
--- /dev/null
+++ b/src/store/migrations/012_issues.sql
@@ -1,0 +1,185 @@
+CREATE TABLE repository_counter (
+    repository_id TEXT PRIMARY KEY
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    next_issue_number INTEGER NOT NULL DEFAULT 1
+        CHECK (next_issue_number >= 1),
+    next_pull_request_number INTEGER NOT NULL DEFAULT 1
+        CHECK (next_pull_request_number >= 1)
+) STRICT;
+
+CREATE TABLE issue (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    number INTEGER NOT NULL CHECK (number >= 1),
+    title TEXT NOT NULL
+        CHECK (length(CAST(title AS BLOB)) BETWEEN 1 AND 200),
+    body TEXT NOT NULL CHECK (length(CAST(body AS BLOB)) <= 262144),
+    state TEXT NOT NULL CHECK (state IN ('open', 'closed')),
+    author_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
+    closed_at INTEGER CHECK (closed_at IS NULL OR closed_at >= created_at),
+    UNIQUE (repository_id, number),
+    CHECK (
+        (state = 'open' AND closed_at IS NULL)
+        OR (state = 'closed' AND closed_at IS NOT NULL)
+    )
+) STRICT;
+
+CREATE INDEX issue_repository_state
+ON issue (repository_id, state, number DESC);
+
+CREATE TABLE issue_comment (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    issue_id TEXT NOT NULL
+        REFERENCES issue (id) ON DELETE RESTRICT,
+    author_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    body TEXT NOT NULL
+        CHECK (length(CAST(body AS BLOB)) BETWEEN 1 AND 262144),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX issue_comment_history
+ON issue_comment (issue_id, created_at, id);
+
+CREATE TABLE label (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    name TEXT NOT NULL CHECK (length(CAST(name AS BLOB)) BETWEEN 1 AND 80),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE UNIQUE INDEX label_repository_name
+ON label (repository_id, name COLLATE NOCASE);
+
+CREATE TABLE issue_label (
+    issue_id TEXT NOT NULL
+        REFERENCES issue (id) ON DELETE RESTRICT,
+    label_id TEXT NOT NULL
+        REFERENCES label (id) ON DELETE RESTRICT,
+    actor_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    PRIMARY KEY (issue_id, label_id)
+) STRICT;
+
+CREATE INDEX issue_label_label
+ON issue_label (label_id, issue_id);
+
+CREATE TABLE issue_assignee (
+    issue_id TEXT NOT NULL
+        REFERENCES issue (id) ON DELETE RESTRICT,
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    actor_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    PRIMARY KEY (issue_id, account_id)
+) STRICT;
+
+CREATE INDEX issue_assignee_account
+ON issue_assignee (account_id, issue_id);
+
+DROP INDEX repository_event_feed;
+ALTER TABLE repository_event RENAME TO repository_event_v11;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    event_id TEXT NOT NULL UNIQUE
+        CHECK (
+            length(event_id) = 32
+            AND event_id = lower(event_id)
+            AND event_id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 1),
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    issue_id TEXT REFERENCES issue (id) ON DELETE RESTRICT,
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted',
+            'issue-created', 'issue-edited', 'issue-commented',
+            'issue-closed', 'issue-reopened',
+            'issue-labeled', 'issue-unlabeled',
+            'issue-assigned', 'issue-unassigned'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    payload_version INTEGER NOT NULL CHECK (payload_version = 1),
+    payload TEXT NOT NULL
+        CHECK (
+            length(payload) BETWEEN 1 AND 1048576
+            AND CASE WHEN json_valid(payload) THEN
+                json_type(payload) = 'object'
+                AND coalesce(json_extract(payload, '$.version') = payload_version, 0)
+            ELSE 0 END
+        ),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (repository_id, sequence),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND issue_id IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        ((kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND issue_id IS NULL
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL))
+        OR
+        (kind LIKE 'issue-%'
+            AND issue_id IS NOT NULL
+            AND source_intent_id IS NULL AND source_ordinal IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event
+    (id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+     issue_id, kind, actor, ref_name, old_target, new_target, payload_version,
+     payload, created_at)
+SELECT
+    id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+    NULL, kind, actor, ref_name, old_target, new_target, payload_version, payload,
+    created_at
+FROM repository_event_v11
+ORDER BY id;
+
+DROP TABLE repository_event_v11;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, sequence DESC);
+
+CREATE INDEX repository_event_issue_timeline
+ON repository_event (issue_id, sequence)
+WHERE issue_id IS NOT NULL;
diff --git a/src/store/mod.rs b/src/store/mod.rs
index b05170aa762a9f770179b97f7afa653cdeb10b85..be514e0df13876bc63893d205f807960c62d096c 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -11,7 +11,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 11;
+const SCHEMA_VERSION: i64 = 12;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -21,7 +21,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 11] = [
+const MIGRATIONS: [&str; 12] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -33,6 +33,7 @@
     include_str!("migrations/009_repository_authorization.sql"),
     include_str!("migrations/010_audit_history.sql"),
     include_str!("migrations/011_domain_events.sql"),
+    include_str!("migrations/012_issues.sql"),
 ];
 
 #[allow(
@@ -115,6 +116,20 @@
     EventPayload,
     #[error("audit event page limit is too large")]
     AuditLimit,
+    #[error("issue does not exist: {0}/{1}#{2}")]
+    IssueNotFound(String, String, i64),
+    #[error("issue access is not authorized")]
+    IssueDenied,
+    #[error("issue is hidden by repository access policy")]
+    IssueHidden,
+    #[error("issue state is already {0}")]
+    IssueState(String),
+    #[error("issue label already has the requested state")]
+    IssueLabelState,
+    #[error("issue assignee already has the requested state")]
+    IssueAssigneeState,
+    #[error("issue assignee does not exist or cannot read the repository: {0}")]
+    IssueAssigneeNotFound(String),
 }
 
 pub(crate) struct Store {
@@ -951,6 +966,7 @@
                         repository_id: repository.id,
                         source_intent_id: None,
                         source_ordinal: None,
+                        issue_id: None,
                         event: &event,
                         actor: repository.owner,
                         ref_name: None,
@@ -975,6 +991,7 @@
                             repository_id: repository.id,
                             source_intent_id: None,
                             source_ordinal: None,
+                            issue_id: None,
                             event: &event,
                             actor: repository.owner,
                             ref_name: Some(&reference.name),
@@ -1351,6 +1368,477 @@
         }
     }
 
+    pub(crate) fn create_issue(&mut self, issue: &NewIssue<'_>) -> Result<IssueRecord, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(
+            &transaction,
+            issue.owner,
+            issue.repository,
+            Some(issue.actor),
+        )?;
+        if !access.can_read() {
+            return Err(StoreError::IssueHidden);
+        }
+        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+        transaction.execute(
+            "INSERT INTO repository_counter (repository_id)
+             VALUES (?1) ON CONFLICT (repository_id) DO NOTHING",
+            [&access.repository.id],
+        )?;
+        let number = transaction.query_row(
+            "UPDATE repository_counter
+             SET next_issue_number = next_issue_number + 1
+             WHERE repository_id = ?1
+             RETURNING next_issue_number - 1",
+            [&access.repository.id],
+            |row| row.get(0),
+        )?;
+        let id: String =
+            transaction.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))?;
+        transaction.execute(
+            "INSERT INTO issue
+             (id, repository_id, number, title, body, state, author_account_id,
+              created_at, updated_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6, ?7, ?7)",
+            rusqlite::params![
+                id,
+                access.repository.id,
+                number,
+                issue.title,
+                issue.body,
+                actor_id,
+                issue.created_at,
+            ],
+        )?;
+        let event = event::issue(
+            event::EventKind::IssueCreated,
+            &id,
+            number,
+            issue.title,
+            issue.body,
+        );
+        insert_domain_event(
+            &transaction,
+            &NewDomainEvent {
+                repository_id: &access.repository.id,
+                source_intent_id: None,
+                source_ordinal: None,
+                issue_id: Some(&id),
+                event: &event,
+                actor: issue.actor,
+                ref_name: None,
+                old_target: None,
+                new_target: None,
+                created_at: issue.created_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(IssueRecord {
+            id,
+            number,
+            title: issue.title.to_owned(),
+            body: issue.body.to_owned(),
+            state: "open".to_owned(),
+            author: issue.actor.to_owned(),
+            created_at: issue.created_at,
+            updated_at: issue.created_at,
+            closed_at: None,
+        })
+    }
+
+    pub(crate) fn edit_issue(
+        &mut self,
+        change: &IssueChange<'_>,
+        title: &str,
+        body: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (access, current) = issue_mutation_context(
+            &transaction,
+            change.owner,
+            change.repository,
+            change.number,
+            change.actor,
+        )?;
+        if !access.can_write_issue(current.author_account_id) {
+            return Err(StoreError::IssueDenied);
+        }
+        transaction.execute(
+            "UPDATE issue SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1",
+            rusqlite::params![current.issue.id, title, body, change.changed_at],
+        )?;
+        let event = event::issue(
+            event::EventKind::IssueEdited,
+            &current.issue.id,
+            change.number,
+            title,
+            body,
+        );
+        insert_issue_event(
+            &transaction,
+            &access.repository.id,
+            &current.issue.id,
+            change.actor,
+            change.changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn comment_issue(
+        &mut self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        body: &str,
+        created_at: i64,
+    ) -> Result<String, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (access, current) =
+            issue_mutation_context(&transaction, owner, repository, number, actor)?;
+        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+        if !access.can_read() {
+            return Err(StoreError::IssueDenied);
+        }
+        let comment_id: String =
+            transaction.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))?;
+        transaction.execute(
+            "INSERT INTO issue_comment
+             (id, issue_id, author_account_id, body, created_at)
+             VALUES (?1, ?2, ?3, ?4, ?5)",
+            rusqlite::params![comment_id, current.issue.id, actor_id, body, created_at],
+        )?;
+        transaction.execute(
+            "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+            rusqlite::params![current.issue.id, created_at],
+        )?;
+        let event = event::issue_comment(&current.issue.id, number, &comment_id, actor, body);
+        insert_issue_event(
+            &transaction,
+            &access.repository.id,
+            &current.issue.id,
+            actor,
+            created_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(comment_id)
+    }
+
+    pub(crate) fn set_issue_state(
+        &mut self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        state: &str,
+        changed_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (access, current) =
+            issue_mutation_context(&transaction, owner, repository, number, actor)?;
+        if !access.can_write_issue(current.author_account_id) {
+            return Err(StoreError::IssueDenied);
+        }
+        if current.issue.state == state {
+            return Err(StoreError::IssueState(state.to_owned()));
+        }
+        let (closed_at, kind) = match state {
+            "closed" => (Some(changed_at), event::EventKind::IssueClosed),
+            "open" => (None, event::EventKind::IssueReopened),
+            _ => return Err(StoreError::IssueState(state.to_owned())),
+        };
+        transaction.execute(
+            "UPDATE issue
+             SET state = ?2, updated_at = ?3, closed_at = ?4
+             WHERE id = ?1",
+            rusqlite::params![current.issue.id, state, changed_at, closed_at],
+        )?;
+        let event = event::issue_state(kind, &current.issue.id, number, state);
+        insert_issue_event(
+            &transaction,
+            &access.repository.id,
+            &current.issue.id,
+            actor,
+            changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn set_issue_label(
+        &mut self,
+        change: &IssueChange<'_>,
+        label: &str,
+        present: bool,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (access, current) = issue_mutation_context(
+            &transaction,
+            change.owner,
+            change.repository,
+            change.number,
+            change.actor,
+        )?;
+        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+        if !access.can_maintain() {
+            return Err(StoreError::IssueDenied);
+        }
+        let (label_id, stored_label) = if present {
+            transaction.execute(
+                "INSERT INTO label (id, repository_id, name, created_at)
+                 VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3)
+                 ON CONFLICT DO NOTHING",
+                rusqlite::params![access.repository.id, label, change.changed_at],
+            )?;
+            transaction.query_row(
+                "SELECT id, name FROM label
+                 WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
+                rusqlite::params![access.repository.id, label],
+                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
+            )?
+        } else {
+            transaction
+                .query_row(
+                    "SELECT id, name FROM label
+                     WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
+                    rusqlite::params![access.repository.id, label],
+                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
+                )
+                .optional()?
+                .ok_or(StoreError::IssueLabelState)?
+        };
+        let changed = if present {
+            transaction.execute(
+                "INSERT INTO issue_label (issue_id, label_id, actor_account_id, created_at)
+                 VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
+                rusqlite::params![current.issue.id, label_id, actor_id, change.changed_at],
+            )?
+        } else {
+            transaction.execute(
+                "DELETE FROM issue_label WHERE issue_id = ?1 AND label_id = ?2",
+                rusqlite::params![current.issue.id, label_id],
+            )?
+        };
+        if changed == 0 {
+            return Err(StoreError::IssueLabelState);
+        }
+        transaction.execute(
+            "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+            rusqlite::params![current.issue.id, change.changed_at],
+        )?;
+        let kind = if present {
+            event::EventKind::IssueLabeled
+        } else {
+            event::EventKind::IssueUnlabeled
+        };
+        let event = event::issue_label(
+            kind,
+            &current.issue.id,
+            change.number,
+            &label_id,
+            &stored_label,
+        );
+        insert_issue_event(
+            &transaction,
+            &access.repository.id,
+            &current.issue.id,
+            change.actor,
+            change.changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn set_issue_assignee(
+        &mut self,
+        change: &IssueChange<'_>,
+        assignee: &str,
+        present: bool,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (access, current) = issue_mutation_context(
+            &transaction,
+            change.owner,
+            change.repository,
+            change.number,
+            change.actor,
+        )?;
+        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
+        if !access.can_maintain() {
+            return Err(StoreError::IssueDenied);
+        }
+        let assignee_access = repository_issue_access(
+            &transaction,
+            change.owner,
+            change.repository,
+            Some(assignee),
+        )?;
+        let assignee_id = assignee_access
+            .active_actor_id
+            .filter(|_| assignee_access.can_read())
+            .ok_or_else(|| StoreError::IssueAssigneeNotFound(assignee.to_owned()))?;
+        let changed = if present {
+            transaction.execute(
+                "INSERT INTO issue_assignee
+                 (issue_id, account_id, actor_account_id, created_at)
+                 VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
+                rusqlite::params![current.issue.id, assignee_id, actor_id, change.changed_at],
+            )?
+        } else {
+            transaction.execute(
+                "DELETE FROM issue_assignee WHERE issue_id = ?1 AND account_id = ?2",
+                rusqlite::params![current.issue.id, assignee_id],
+            )?
+        };
+        if changed == 0 {
+            return Err(StoreError::IssueAssigneeState);
+        }
+        transaction.execute(
+            "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
+            rusqlite::params![current.issue.id, change.changed_at],
+        )?;
+        let kind = if present {
+            event::EventKind::IssueAssigned
+        } else {
+            event::EventKind::IssueUnassigned
+        };
+        let event = event::issue_assignee(kind, &current.issue.id, change.number, assignee);
+        insert_issue_event(
+            &transaction,
+            &access.repository.id,
+            &current.issue.id,
+            change.actor,
+            change.changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn issues(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+    ) -> Result<(RepositoryRecord, Vec<IssueRecord>), StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::IssueHidden);
+        }
+        let mut statement = self.connection.prepare(
+            "SELECT issue.id, issue.number, issue.title, issue.body, issue.state,
+                    account.username, issue.created_at, issue.updated_at, issue.closed_at
+             FROM issue
+             JOIN account ON account.id = issue.author_account_id
+             WHERE issue.repository_id = ?1
+             ORDER BY issue.number DESC LIMIT 1000",
+        )?;
+        let issues = statement
+            .query_map([&access.repository.id], issue_from_row)?
+            .collect::<Result<Vec<_>, _>>()?;
+        drop(statement);
+        Ok((access.repository, issues))
+    }
+
+    pub(crate) fn issue_detail(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+    ) -> Result<IssueDetail, StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::IssueHidden);
+        }
+        let issue = find_issue(
+            &self.connection,
+            &access.repository,
+            owner,
+            repository,
+            number,
+        )?;
+        let comments = {
+            let mut statement = self.connection.prepare(
+                "SELECT issue_comment.id, account.username, issue_comment.body,
+                        issue_comment.created_at
+                 FROM issue_comment
+                 JOIN account ON account.id = issue_comment.author_account_id
+                 WHERE issue_comment.issue_id = ?1
+                 ORDER BY issue_comment.created_at, issue_comment.id",
+            )?;
+            statement
+                .query_map([&issue.issue.id], |row| {
+                    Ok(IssueCommentRecord {
+                        id: row.get(0)?,
+                        author: row.get(1)?,
+                        body: row.get(2)?,
+                        created_at: row.get(3)?,
+                    })
+                })?
+                .collect::<Result<Vec<_>, _>>()?
+        };
+        let labels = issue_names(
+            &self.connection,
+            "SELECT label.name FROM issue_label
+             JOIN label ON label.id = issue_label.label_id
+             WHERE issue_label.issue_id = ?1 ORDER BY label.name COLLATE NOCASE",
+            &issue.issue.id,
+        )?;
+        let assignees = issue_names(
+            &self.connection,
+            "SELECT account.username FROM issue_assignee
+             JOIN account ON account.id = issue_assignee.account_id
+             WHERE issue_assignee.issue_id = ?1 ORDER BY account.username",
+            &issue.issue.id,
+        )?;
+        let timeline = {
+            let mut statement = self.connection.prepare(
+                "SELECT sequence, kind, actor, payload, created_at
+                 FROM repository_event WHERE issue_id = ?1 ORDER BY sequence",
+            )?;
+            statement
+                .query_map([&issue.issue.id], |row| {
+                    Ok(IssueTimelineRecord {
+                        sequence: row.get(0)?,
+                        kind: row.get(1)?,
+                        actor: row.get(2)?,
+                        payload: row.get(3)?,
+                        created_at: row.get(4)?,
+                    })
+                })?
+                .collect::<Result<Vec<_>, _>>()?
+        };
+        Ok(IssueDetail {
+            can_comment: access.active_actor_id.is_some() && access.can_read(),
+            can_edit: access.can_write_issue(issue.author_account_id),
+            can_maintain: access.can_maintain(),
+            repository: access.repository,
+            issue: issue.issue,
+            comments,
+            labels,
+            assignees,
+            timeline,
+        })
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests compile storage without authorization"
@@ -1703,6 +2191,65 @@
     pub(crate) created_at: i64,
 }
 
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueRecord {
+    pub(crate) id: String,
+    pub(crate) number: i64,
+    pub(crate) title: String,
+    pub(crate) body: String,
+    pub(crate) state: String,
+    pub(crate) author: String,
+    pub(crate) created_at: i64,
+    pub(crate) updated_at: i64,
+    pub(crate) closed_at: Option<i64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueCommentRecord {
+    pub(crate) id: String,
+    pub(crate) author: String,
+    pub(crate) body: String,
+    pub(crate) created_at: i64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct IssueTimelineRecord {
+    pub(crate) sequence: i64,
+    pub(crate) kind: String,
+    pub(crate) actor: String,
+    pub(crate) payload: String,
+    pub(crate) created_at: i64,
+}
+
+pub(crate) struct IssueDetail {
+    pub(crate) repository: RepositoryRecord,
+    pub(crate) issue: IssueRecord,
+    pub(crate) comments: Vec<IssueCommentRecord>,
+    pub(crate) labels: Vec<String>,
+    pub(crate) assignees: Vec<String>,
+    pub(crate) timeline: Vec<IssueTimelineRecord>,
+    pub(crate) can_comment: bool,
+    pub(crate) can_edit: bool,
+    pub(crate) can_maintain: bool,
+}
+
+pub(crate) struct NewIssue<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) actor: &'a str,
+    pub(crate) title: &'a str,
+    pub(crate) body: &'a str,
+    pub(crate) created_at: i64,
+}
+
+pub(crate) struct IssueChange<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) number: i64,
+    pub(crate) actor: &'a str,
+    pub(crate) changed_at: i64,
+}
+
 pub(crate) struct GitOperationIntent<'a> {
     pub(crate) id: &'a str,
     pub(crate) repository_path: &'a str,
@@ -1802,10 +2349,186 @@
     Ok(())
 }
 
+struct RepositoryIssueAccess {
+    repository: RepositoryRecord,
+    active_actor_id: Option<i64>,
+    role: Option<String>,
+}
+
+impl RepositoryIssueAccess {
+    fn can_read(&self) -> bool {
+        self.repository.state == "active"
+            && (self.repository.visibility == "public" || self.role.is_some())
+    }
+
+    fn can_write_issue(&self, author_account_id: i64) -> bool {
+        self.can_read()
+            && (self.active_actor_id == Some(author_account_id)
+                || matches!(
+                    self.role.as_deref(),
+                    Some("owner" | "maintainer" | "writer")
+                ))
+    }
+
+    fn can_maintain(&self) -> bool {
+        self.can_read() && matches!(self.role.as_deref(), Some("owner" | "maintainer"))
+    }
+}
+
+struct StoredIssue {
+    issue: IssueRecord,
+    author_account_id: i64,
+}
+
+fn repository_issue_access(
+    connection: &Connection,
+    owner: &str,
+    repository: &str,
+    actor: Option<&str>,
+) -> Result<RepositoryIssueAccess, StoreError> {
+    let result = connection.query_row(
+        "SELECT repository.id, owner.username, repository.slug,
+                repository.visibility, repository.state, repository.object_format,
+                repository.created_at, repository.archived_at,
+                CASE WHEN actor.state = 'active' THEN actor.id END,
+                CASE
+                    WHEN actor.state != 'active' THEN NULL
+                    WHEN actor.id = repository.owner_account_id THEN 'owner'
+                    ELSE repository_collaborator.role
+                END
+         FROM repository
+         JOIN account AS owner ON owner.id = repository.owner_account_id
+         LEFT JOIN account AS actor ON actor.username = ?3
+         LEFT JOIN repository_collaborator
+           ON repository_collaborator.repository_id = repository.id
+          AND repository_collaborator.account_id = actor.id
+         WHERE owner.username = ?1 AND repository.slug = ?2",
+        rusqlite::params![owner, repository, actor],
+        |row| {
+            Ok(RepositoryIssueAccess {
+                repository: repository_from_row(row)?,
+                active_actor_id: row.get(8)?,
+                role: row.get(9)?,
+            })
+        },
+    );
+    match result {
+        Ok(access) => Ok(access),
+        Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::RepositoryNotFound(
+            owner.to_owned(),
+            repository.to_owned(),
+        )),
+        Err(error) => Err(error.into()),
+    }
+}
+
+fn find_issue(
+    connection: &Connection,
+    repository: &RepositoryRecord,
+    owner: &str,
+    slug: &str,
+    number: i64,
+) -> Result<StoredIssue, StoreError> {
+    let result = connection.query_row(
+        "SELECT issue.id, issue.number, issue.title, issue.body, issue.state,
+                account.username, issue.created_at, issue.updated_at, issue.closed_at,
+                issue.author_account_id
+         FROM issue
+         JOIN account ON account.id = issue.author_account_id
+         WHERE issue.repository_id = ?1 AND issue.number = ?2",
+        rusqlite::params![repository.id, number],
+        |row| {
+            Ok(StoredIssue {
+                issue: issue_from_row(row)?,
+                author_account_id: row.get(9)?,
+            })
+        },
+    );
+    match result {
+        Ok(issue) => Ok(issue),
+        Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::IssueNotFound(
+            owner.to_owned(),
+            slug.to_owned(),
+            number,
+        )),
+        Err(error) => Err(error.into()),
+    }
+}
+
+fn issue_mutation_context(
+    connection: &Connection,
+    owner: &str,
+    repository: &str,
+    number: i64,
+    actor: &str,
+) -> Result<(RepositoryIssueAccess, StoredIssue), StoreError> {
+    let access = repository_issue_access(connection, owner, repository, Some(actor))?;
+    if !access.can_read() {
+        return Err(StoreError::IssueHidden);
+    }
+    if access.active_actor_id.is_none() {
+        return Err(StoreError::IssueDenied);
+    }
+    let issue = find_issue(connection, &access.repository, owner, repository, number)?;
+    Ok((access, issue))
+}
+
+fn issue_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IssueRecord> {
+    Ok(IssueRecord {
+        id: row.get(0)?,
+        number: row.get(1)?,
+        title: row.get(2)?,
+        body: row.get(3)?,
+        state: row.get(4)?,
+        author: row.get(5)?,
+        created_at: row.get(6)?,
+        updated_at: row.get(7)?,
+        closed_at: row.get(8)?,
+    })
+}
+
+fn issue_names(
+    connection: &Connection,
+    sql: &str,
+    issue_id: &str,
+) -> Result<Vec<String>, StoreError> {
+    let mut statement = connection.prepare(sql)?;
+    statement
+        .query_map([issue_id], |row| row.get(0))?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(Into::into)
+}
+
+fn insert_issue_event(
+    transaction: &rusqlite::Transaction<'_>,
+    repository_id: &str,
+    issue_id: &str,
+    actor: &str,
+    created_at: i64,
+    event: &event::VersionedEvent,
+) -> Result<(), StoreError> {
+    insert_domain_event(
+        transaction,
+        &NewDomainEvent {
+            repository_id,
+            source_intent_id: None,
+            source_ordinal: None,
+            issue_id: Some(issue_id),
+            event,
+            actor,
+            ref_name: None,
+            old_target: None,
+            new_target: None,
+            created_at,
+        },
+    )
+}
+
 struct NewDomainEvent<'a> {
     repository_id: &'a str,
     source_intent_id: Option<&'a str>,
     source_ordinal: Option<i64>,
+    issue_id: Option<&'a str>,
     event: &'a event::VersionedEvent,
     actor: &'a str,
     ref_name: Option<&'a [u8]>,
@@ -1821,19 +2544,20 @@
     transaction.execute(
         "INSERT INTO repository_event
          (event_id, repository_id, sequence, source_intent_id, source_ordinal,
-          kind, actor, ref_name, old_target, new_target, payload_version, payload,
+          issue_id, kind, actor, ref_name, old_target, new_target, payload_version, payload,
           created_at)
          VALUES (
              lower(hex(randomblob(16))),
              ?1,
              (SELECT COALESCE(MAX(sequence), 0) + 1
               FROM repository_event WHERE repository_id = ?1),
-             ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
+             ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
          )",
         rusqlite::params![
             event.repository_id,
             event.source_intent_id,
             event.source_ordinal,
+            event.issue_id,
             event.event.kind.as_str(),
             event.actor,
             event.ref_name,
@@ -1895,6 +2619,7 @@
             repository_id,
             source_intent_id: Some(intent_id),
             source_ordinal: Some(0),
+            issue_id: None,
             event: &push,
             actor,
             ref_name: None,
@@ -1947,6 +2672,7 @@
                 repository_id,
                 source_intent_id: Some(intent_id),
                 source_ordinal: Some(ordinal),
+                issue_id: None,
                 event: &event,
                 actor,
                 ref_name: Some(&old_name),
diff --git a/templates/issue.html b/templates/issue.html
new file mode 100644
index 0000000000000000000000000000000000000000..7a861e99964871167a699fde1f5e67292addef78
--- /dev/null
+++ b/templates/issue.html
@@ -1,0 +1,115 @@
+{% extends "base.html" %}
+{% block title %}#{{ number }} {{ title }} · {{ owner }}/{{ repository }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+    </nav>
+  </header>
+
+  <article>
+    <h2>#{{ number }} {{ title }}</h2>
+    <p>{{ state }} · opened by {{ author }} at <time>{{ created_at }}</time> · updated <time>{{ updated_at }}</time></p>
+{% if labels.is_empty() %}{% else %}
+    <p>Labels:{% for label in labels %} <span>{{ label }}</span>{% endfor %}</p>
+{% endif %}
+{% if assignees.is_empty() %}{% else %}
+    <p>Assignees:{% for assignee in assignees %} <span>{{ assignee }}</span>{% endfor %}</p>
+{% endif %}
+    <div class="markdown">{{ body_html|safe }}</div>
+  </article>
+
+  <section aria-labelledby="comments-heading">
+    <h2 id="comments-heading">Comments</h2>
+{% if comments.is_empty() %}
+    <p>This issue has no comments.</p>
+{% else %}
+{% for comment in comments %}
+    <article id="comment-{{ comment.id }}">
+      <h3>{{ comment.author }} commented at <time>{{ comment.created_at }}</time></h3>
+      <div class="markdown">{{ comment.body_html|safe }}</div>
+    </article>
+{% endfor %}
+{% endif %}
+  </section>
+
+  <section aria-labelledby="timeline-heading">
+    <h2 id="timeline-heading">Timeline</h2>
+    <ol>
+{% for event in timeline %}
+      <li value="{{ event.sequence }}">{{ event.actor }}: {{ event.kind }} at <time>{{ event.created_at }}</time></li>
+{% endfor %}
+    </ol>
+  </section>
+
+{% if can_comment %}
+  <section aria-labelledby="comment-heading">
+    <h2 id="comment-heading">Add a comment</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/comments">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="comment-body">Comment (Markdown)</label>
+        <textarea id="comment-body" name="body" maxlength="262144" rows="8" required></textarea>
+      </div>
+      <button type="submit">Add comment</button>
+    </form>
+  </section>
+{% endif %}
+
+{% if can_edit %}
+  <section aria-labelledby="edit-heading">
+    <h2 id="edit-heading">Edit this issue</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/edit">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="edit-title">Title</label>
+        <input id="edit-title" name="title" maxlength="200" value="{{ title }}" required>
+      </div>
+      <div class="field">
+        <label for="edit-body">Description (Markdown)</label>
+        <textarea id="edit-body" name="body" maxlength="262144" rows="10">{{ body }}</textarea>
+      </div>
+      <button type="submit">Save issue</button>
+    </form>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/state">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+{% if is_open %}
+      <input type="hidden" name="state" value="closed">
+      <button type="submit">Close issue</button>
+{% else %}
+      <input type="hidden" name="state" value="open">
+      <button type="submit">Reopen issue</button>
+{% endif %}
+    </form>
+  </section>
+{% endif %}
+
+{% if can_maintain %}
+  <section aria-labelledby="organize-heading">
+    <h2 id="organize-heading">Organize this issue</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/labels">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="label-name">Label</label>
+        <input id="label-name" name="label" maxlength="80" required>
+      </div>
+      <button name="operation" value="add" type="submit">Add label</button>
+      <button name="operation" value="remove" type="submit">Remove label</button>
+    </form>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/assignees">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="assignee-name">Assignee</label>
+        <input id="assignee-name" name="assignee" maxlength="39" required>
+      </div>
+      <button name="operation" value="add" type="submit">Assign</button>
+      <button name="operation" value="remove" type="submit">Unassign</button>
+    </form>
+  </section>
+{% endif %}
+{% endblock %}
diff --git a/templates/issues.html b/templates/issues.html
new file mode 100644
index 0000000000000000000000000000000000000000..7d5be43d1092f8edd96304d04aa2e6f740f3223b
--- /dev/null
+++ b/templates/issues.html
@@ -1,0 +1,45 @@
+{% extends "base.html" %}
+{% block title %}Issues · {{ owner }}/{{ repository }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+    </nav>
+  </header>
+
+  <h2>Issues</h2>
+{% if issues.is_empty() %}
+  <p>This repository has no issues.</p>
+{% else %}
+  <ol class="issue-list">
+{% for issue in issues %}
+    <li><a href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> — {{ issue.state }}, opened by {{ issue.author }}, updated <time>{{ issue.updated_at }}</time></li>
+{% endfor %}
+  </ol>
+{% endif %}
+
+{% if can_create %}
+  <section aria-labelledby="new-issue-heading">
+    <h2 id="new-issue-heading">Create an issue</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/issues">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="issue-title">Title</label>
+        <input id="issue-title" name="title" maxlength="200" required>
+      </div>
+      <div class="field">
+        <label for="issue-body">Description (Markdown)</label>
+        <textarea id="issue-body" name="body" maxlength="262144" rows="10"></textarea>
+      </div>
+      <button type="submit">Create issue</button>
+    </form>
+  </section>
+{% else %}
+  <p><a href="/login">Log in</a> to create an issue.</p>
+{% endif %}
+{% endblock %}
diff --git a/templates/repository.html b/templates/repository.html
index 933d7c5a09ffb455d7b032c326787987fe310dd4..bcff56df13b0a24a20bef66b030c73adcd220012 100644
--- a/templates/repository.html
+++ b/templates/repository.html
@@ -6,6 +6,7 @@
     <nav aria-label="Repository">
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
       <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
       <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
       <a href="/{{ owner }}/{{ repository }}/search">Search</a>
diff --git a/tests/cli.rs b/tests/cli.rs
index 1255ad2d777fab15cbdaef374b91d642276991e7..643afe29008584de07c59f549baf8feae9eec9b8 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -20,7 +20,8 @@
     include_str!("../src/store/migrations/009_repository_authorization.sql"),
     include_str!("../src/store/migrations/010_audit_history.sql"),
     include_str!("../src/store/migrations/011_domain_events.sql"),
-    "PRAGMA user_version = 11;\n",
+    include_str!("../src/store/migrations/012_issues.sql"),
+    "PRAGMA user_version = 12;\n",
 );
 
 #[test]
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index 052d69b9b0b96f5d52f51f363135e4f1e8c12880..07446caa6e1c0938c7c9fcb7c6855ee705ea799c 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -32,6 +32,9 @@
 )]
 #[path = "../src/instance.rs"]
 mod instance;
+#[allow(dead_code, reason = "the public-route test does not mutate issues")]
+#[path = "../src/issue.rs"]
+mod issue;
 #[path = "../src/markdown.rs"]
 mod markdown;
 #[allow(dead_code, reason = "the public-route test uses anonymous policy only")]
@@ -59,8 +62,10 @@
 use std::net::{Ipv4Addr, SocketAddr, TcpStream};
 use std::path::Path;
 use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
 
 use http::{PublicWebConfig, RunningWebServer};
+use sha2::{Digest, Sha256};
 use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
 use tempfile::TempDir;
 
@@ -563,6 +568,168 @@
 
         server.shutdown().await.expect("stop the public Web server");
     }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn runs_the_complete_issue_workflow_without_javascript() {
+    let fixture = Fixture::new("sha1");
+    let database = fixture.instance.path().join(store::DATABASE_FILE);
+    let token = "11".repeat(32);
+    let csrf = "22".repeat(32);
+    let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
+    let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
+    let now = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("read current time")
+        .as_secs() as i64;
+    let store = Store::open(&database).expect("open the issue fixture database");
+    store
+        .connection()
+        .execute(
+            "INSERT INTO web_session
+             (session_hash, csrf_hash, account_id, created_at, expires_at)
+             SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'alice'",
+            rusqlite::params![session_hash, csrf_hash, now, now + 3600,],
+        )
+        .expect("create a Web session");
+    drop(store);
+
+    let server = RunningWebServer::start_public(
+        SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+        PublicWebConfig {
+            instance_dir: fixture.instance.path().to_owned(),
+            http_clone_base: "http://127.0.0.1".to_owned(),
+            ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+        },
+    )
+    .await
+    .expect("start the issue Web server");
+
+    let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
+    assert_eq!(anonymous.status, 200);
+    assert!(anonymous.text().contains("This repository has no issues."));
+    assert!(!anonymous.text().contains("Create an issue</h2>"));
+
+    let cookie = format!("tit-session={token}; tit-csrf={csrf}");
+    let headers = [
+        ("Content-Type", "application/x-www-form-urlencoded"),
+        ("Cookie", cookie.as_str()),
+    ];
+    let create = form(&[
+        ("csrf", &csrf),
+        ("title", "Unsafe rendering check"),
+        ("body", "**safe**\n\n<script>alert(1)</script>"),
+    ]);
+    let created = request(
+        server.address(),
+        "POST",
+        "/alice/example/issues",
+        &headers,
+        create.as_bytes(),
+    );
+    assert_eq!(created.status, 303);
+    assert_eq!(created.header("location"), "/alice/example/issues/1");
+
+    let detail = request(
+        server.address(),
+        "GET",
+        "/alice/example/issues/1",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert_eq!(detail.status, 200);
+    assert!(detail.text().contains("<strong>safe</strong>"));
+    assert!(!detail.text().contains("<script>"));
+    assert!(detail.text().contains("Add a comment"));
+    assert!(detail.text().contains("Organize this issue"));
+
+    let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/example/issues/1/state",
+            &headers,
+            bad_csrf.as_bytes(),
+        )
+        .status,
+        403
+    );
+    for (path, fields) in [
+        (
+            "/alice/example/issues/1/comments",
+            vec![("csrf", csrf.as_str()), ("body", "A **comment**.")],
+        ),
+        (
+            "/alice/example/issues/1/edit",
+            vec![
+                ("csrf", csrf.as_str()),
+                ("title", "Edited issue"),
+                ("body", "Preserved _Markdown_."),
+            ],
+        ),
+        (
+            "/alice/example/issues/1/labels",
+            vec![
+                ("csrf", csrf.as_str()),
+                ("label", "bug"),
+                ("operation", "add"),
+            ],
+        ),
+        (
+            "/alice/example/issues/1/assignees",
+            vec![
+                ("csrf", csrf.as_str()),
+                ("assignee", "alice"),
+                ("operation", "add"),
+            ],
+        ),
+        (
+            "/alice/example/issues/1/state",
+            vec![("csrf", csrf.as_str()), ("state", "closed")],
+        ),
+        (
+            "/alice/example/issues/1/state",
+            vec![("csrf", csrf.as_str()), ("state", "open")],
+        ),
+    ] {
+        let response = request(
+            server.address(),
+            "POST",
+            path,
+            &headers,
+            form(&fields).as_bytes(),
+        );
+        assert_eq!(response.status, 303, "issue mutation failed at {path}");
+    }
+
+    let final_page = request(
+        server.address(),
+        "GET",
+        "/alice/example/issues/1",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    let final_text = final_page.text();
+    assert_eq!(final_page.status, 200);
+    assert!(final_text.contains("#1 Edited issue"));
+    assert!(final_text.contains("<em>Markdown</em>"));
+    assert!(final_text.contains("<strong>comment</strong>"));
+    assert!(final_text.contains("Labels: <span>bug</span>"));
+    assert!(final_text.contains("Assignees: <span>alice</span>"));
+    assert!(final_text.contains("issue-created"));
+    assert!(final_text.contains("issue-reopened"));
+    let feed = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+    assert_eq!(feed.status, 200);
+    assert!(feed.text().contains("alice reopened #1"));
+
+    server.shutdown().await.expect("stop the issue Web server");
+}
+
+fn form(fields: &[(&str, &str)]) -> String {
+    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
+    serializer.extend_pairs(fields.iter().copied());
+    serializer.finish()
 }
 
 fn assert_hidden(address: SocketAddr, head: &str) {
diff --git a/tests/sqlite.rs b/tests/sqlite.rs
index 890c6879b57984c6289dfbb1ad0f5c3d6363a144..7b1da56c74951e487c0c9bf7233d75f6eccbff1e 100644
--- a/tests/sqlite.rs
+++ b/tests/sqlite.rs
@@ -10,8 +10,8 @@
 
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
 use store::{
-    GitOperationIntent, InitialAdministrator, NewAuditEvent, NewRepository, NewRepositoryReference,
-    RepositoryOrigin, Store, StoreError,
+    GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue, NewRepository,
+    NewRepositoryReference, RepositoryOrigin, Store, StoreError,
 };
 use tempfile::TempDir;
 
@@ -39,6 +39,14 @@
     include_str!("../src/store/migrations/009_repository_authorization.sql"),
     include_str!("../src/store/migrations/010_audit_history.sql"),
     "PRAGMA user_version = 10;\n",
+);
+const V11_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    include_str!("../src/store/migrations/011_domain_events.sql"),
+    "PRAGMA user_version = 11;\n",
 );
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
@@ -157,7 +165,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 11);
+    assert_eq!(store.schema_version().expect("read the schema version"), 12);
     assert_eq!(
         store
             .connection()
@@ -678,6 +686,221 @@
 }
 
 #[test]
+fn runs_the_issue_workflow_with_atomic_events_and_repository_roles() {
+    let directory = TempDir::new().expect("create an issue fixture directory");
+    let mut store = Store::open(&database(&directory, "issues.sqlite")).expect("open the store");
+    store
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: "ssh-ed25519 AAAAalice",
+            fingerprint: "SHA256:alice",
+            recovery_hash: &[7; 32],
+            created_at: 1,
+        })
+        .expect("create the repository owner");
+    store
+        .connection()
+        .execute_batch(
+            "INSERT INTO account (id, username, is_administrator, state, created_at) VALUES
+                 (2, 'bob', 0, 'active', 1),
+                 (3, 'carol', 0, 'active', 1),
+                 (4, 'maintainer', 0, 'active', 1),
+                 (5, 'stranger', 0, 'active', 1),
+                 (6, 'suspended', 0, 'suspended', 1);",
+        )
+        .expect("create issue actors");
+    store
+        .create_repository(&NewRepository {
+            id: "00112233445566778899aabbccddeeff",
+            owner: "alice",
+            slug: "project",
+            object_format: "sha1",
+            created_at: 2,
+            origin: RepositoryOrigin::Created,
+            initial_references: &[],
+            actor: "alice",
+            correlation_id: "issue-repository",
+        })
+        .expect("create an issue repository");
+    store
+        .connection()
+        .execute_batch(
+            "UPDATE repository SET visibility = 'private';
+             INSERT INTO repository_collaborator
+                 (repository_id, account_id, role, created_at)
+             VALUES
+                 ('00112233445566778899aabbccddeeff', 2, 'reader', 3),
+                 ('00112233445566778899aabbccddeeff', 3, 'writer', 3),
+                 ('00112233445566778899aabbccddeeff', 4, 'maintainer', 3);",
+        )
+        .expect("configure issue roles");
+
+    let source = "Use **the supplied Markdown**.\n\n<script>do not run</script>";
+    let issue = store
+        .create_issue(&NewIssue {
+            owner: "alice",
+            repository: "project",
+            actor: "bob",
+            title: "Preserve Markdown",
+            body: source,
+            created_at: 4,
+        })
+        .expect("create an issue as a reader");
+    assert_eq!(issue.number, 1);
+    assert_eq!(issue.body, source);
+    let second = store
+        .create_issue(&NewIssue {
+            owner: "alice",
+            repository: "project",
+            actor: "carol",
+            title: "Second issue",
+            body: "",
+            created_at: 5,
+        })
+        .expect("allocate the next issue number");
+    assert_eq!(second.number, 2);
+    assert!(matches!(
+        store.issues("alice", "project", None),
+        Err(StoreError::IssueHidden)
+    ));
+    assert_eq!(
+        store
+            .issues("alice", "project", Some("bob"))
+            .expect("list private issues as a reader")
+            .1
+            .len(),
+        2
+    );
+    let change = |actor, changed_at| IssueChange {
+        owner: "alice",
+        repository: "project",
+        number: 1,
+        actor,
+        changed_at,
+    };
+    assert!(matches!(
+        store.edit_issue(
+            &IssueChange {
+                number: 2,
+                ..change("bob", 6)
+            },
+            "Reader edit",
+            "denied",
+        ),
+        Err(StoreError::IssueDenied)
+    ));
+    store
+        .edit_issue(&change("bob", 7), "Preserve the source", source)
+        .expect("edit an authored issue");
+    store
+        .edit_issue(&change("carol", 8), "Writer edit", source)
+        .expect("edit an issue as a writer");
+    let comment_id = store
+        .comment_issue(
+            "alice",
+            "project",
+            1,
+            "bob",
+            "A comment with [text](https://example.com).",
+            9,
+        )
+        .expect("comment as a reader");
+    assert_eq!(comment_id.len(), 32);
+    assert!(matches!(
+        store.set_issue_label(&change("bob", 10), "bug", true),
+        Err(StoreError::IssueDenied)
+    ));
+    store
+        .set_issue_label(&change("maintainer", 11), "bug", true)
+        .expect("label as a maintainer");
+    store
+        .set_issue_assignee(&change("maintainer", 12), "bob", true)
+        .expect("assign a repository reader");
+    assert!(matches!(
+        store.set_issue_assignee(&change("maintainer", 13), "stranger", true),
+        Err(StoreError::IssueAssigneeNotFound(username)) if username == "stranger"
+    ));
+    store
+        .set_issue_state("alice", "project", 1, "bob", "closed", 14)
+        .expect("close an authored issue");
+    store
+        .set_issue_state("alice", "project", 1, "carol", "open", 15)
+        .expect("reopen an issue as a writer");
+
+    let detail = store
+        .issue_detail("alice", "project", 1, Some("maintainer"))
+        .expect("read the issue timeline");
+    assert_eq!(detail.repository.slug, "project");
+    assert_eq!(detail.issue.title, "Writer edit");
+    assert_eq!(detail.issue.body, source);
+    assert_eq!(detail.issue.state, "open");
+    assert_eq!(detail.comments.len(), 1);
+    assert_eq!(detail.labels, ["bug"]);
+    assert_eq!(detail.assignees, ["bob"]);
+    assert!(detail.can_comment);
+    assert!(detail.can_edit);
+    assert!(detail.can_maintain);
+    assert_eq!(
+        detail
+            .timeline
+            .iter()
+            .map(|event| event.kind.as_str())
+            .collect::<Vec<_>>(),
+        [
+            "issue-created",
+            "issue-edited",
+            "issue-edited",
+            "issue-commented",
+            "issue-labeled",
+            "issue-assigned",
+            "issue-closed",
+            "issue-reopened",
+        ]
+    );
+    assert!(
+        detail
+            .timeline
+            .windows(2)
+            .all(|events| events[0].sequence < events[1].sequence)
+    );
+    for event in &detail.timeline {
+        let payload: serde_json::Value =
+            serde_json::from_str(&event.payload).expect("parse an issue event payload");
+        assert_eq!(payload["version"], 1);
+        assert_eq!(payload["issue_id"], issue.id);
+        assert!(event.created_at >= 4);
+        assert!(!event.actor.is_empty());
+    }
+
+    let comments_before: i64 = store
+        .connection()
+        .query_row("SELECT count(*) FROM issue_comment", [], |row| row.get(0))
+        .expect("count issue comments");
+    store
+        .connection()
+        .execute_batch(
+            "CREATE TEMP TRIGGER reject_issue_event
+             BEFORE INSERT ON repository_event
+             BEGIN
+                 SELECT RAISE(ABORT, 'injected issue event failure');
+             END;",
+        )
+        .expect("inject an issue event failure");
+    assert!(matches!(
+        store.comment_issue("alice", "project", 1, "bob", "must roll back", 16),
+        Err(StoreError::Sqlite(_))
+    ));
+    assert_eq!(
+        store
+            .connection()
+            .query_row("SELECT count(*) FROM issue_comment", [], |row| row
+                .get::<_, i64>(0))
+            .expect("count comments after rollback"),
+        comments_before
+    );
+}
+
+#[test]
 fn enforces_constraints_and_supports_crud_and_indexed_scans() {
     let directory = TempDir::new().expect("create a temporary directory");
     let mut store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
@@ -1015,13 +1238,14 @@
         (V8_FIXTURE, 8),
         (V9_FIXTURE, 9),
         (V10_FIXTURE, 10),
+        (V11_FIXTURE, 11),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 11);
+        assert_eq!(store.schema_version().expect("read the schema version"), 12);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1094,7 +1318,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 11)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 12)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);
diff --git a/tests/web_shell.rs b/tests/web_shell.rs
index 3db8b42848b7ad686d6996e59965e377c000f264..b8461ac1635a29e41f064b6918a358d8ce56a5b7 100644
--- a/tests/web_shell.rs
+++ b/tests/web_shell.rs
@@ -30,6 +30,9 @@
 )]
 #[path = "../src/instance.rs"]
 mod instance;
+#[allow(dead_code, reason = "the Web shell test does not use issue workflows")]
+#[path = "../src/issue.rs"]
+mod issue;
 #[path = "../src/markdown.rs"]
 mod markdown;
 #[allow(dead_code, reason = "the Web shell test has no repository catalog")]
