architecture¶
two services and one postgres.
flowchart LR
user([reviewer]) --> editor
user --> board
editor[editor<br/>hedgedoc 1.x fork] --> pg[(postgres)]
board[spec board<br/>node poller + web] --> pg
board -->|signed mutations| editor
board --> gh[(github)]
board -.-> smtp[(smtp)]
board -.-> bots[(review bot endpoints)]
editor¶
hedgedoc 1.x plus criticmarkup review: inline comment threads, suggestions with
accept/reject, approvals in the navbar, a /new/spec template, deep links to
threads. the rebrand is applied at image build time, so the source tree stays
upstream-clean (releases).
| property | value | why it is fixed |
|---|---|---|
| replicas | 1 | hedgedoc 1.x holds note state in process; a second replica diverges |
| rollout | replace, never parallel | same reason |
| uploads | filesystem volume, RWO | CMD_IMAGE_UPLOAD_TYPE=filesystem |
| login | github oauth, no repo scope; personal tokens for the note API |
tokens inherit note permissions and do not grant browser or approval access |
hedgedoc stores each user's oauth token in Users.accessToken in plaintext.
that is upstream's design, and the reason the login asks for no repo access.
personal access tokens are separate: only their SHA-256 hashes and management
metadata are stored. users create and revoke them from a session-authenticated
page with CSRF protection. the bearer-only note API runs before browser-session
middleware, so an API request neither creates a session nor gains its privileges.
API updates require the note's current ETag. a per-note reservation excludes open editors, pending connections and saves until the conditional database write finishes. this relies on the single-editor-process deployment above. unchanged text keeps its authorship; API edits enter the normal revision saver. the save coordinator retains one active snapshot and one latest pending snapshot, merging participant history. approvals wait for an exact saved-text acknowledgement. external account keys include the provider; legacy accounts are adopted only when their stored provider and subject match the login.
spec board¶
one node process: a poll loop and a small web ui. it owns the spec_board_*
tables and reads hedgedoc's tables. the editor owns all note mutations.
- finds specs by frontmatter (
tags: [spec, <status>]), resolves approvers from.specs/roles.ymlin the target repo, opens the spec PR once quorum is met and every comment thread is resolved. a spec re-reviewed after that PR merged republishes as a revision PR on the same file, keeping its number. - asks the editor's signed mutation API to lock notes or append bot comments. active notes refuse mutations until their editors close. durable permission intents and editor receipts recover a lost response without overwriting an owner override; the editor preserves human authorship around generated text.
- recovers published text from the PR's merge commit. a per-spec generation guards publication state, snapshots and queued events, so a delayed response from a previous poller cannot overwrite a newer publication.
- keeps its own copy of a spec's published text at each status change,
approval and publish (
spec_board_snapshots);/changesdiffs those rows and they tell an approver the text moved past them. the text is already public through/api/specs; the rows add only who approved what. - github access is per project: an app installation token where the app is installed, otherwise the service PAT.
- derives a map of the approved and implemented specs from their
depends-on,supersedesand area declarations, and publishes it on/mapand as a generatedREADME.mdriding in each spec pr. a note markedkind: top-levelis a spec every other spec inherits: unnumbered, listed first, and fed to the review bot and the checkpoint overlap pass as context. a review also carries the spec's declared and same-area neighbours, so a contradiction between two feature specs surfaces while both are still cheap to change. - tags a reconciled spec corpus as
specs/vNon the project's repo when a board admin cuts a checkpoint. the annotated tag and its message are the whole record; no board table backs it. - serves the corpus as json at
/api/specsfor tools outside the browser (reading specs elsewhere), including acurrentrevision hedgedoc's own revision list cannot offer. - resolves a spec reference at
/spec/<owner>/<repo>/<n>, redirecting to the note or, failing that, the pull request. it is the only side that maps a spec number to a note, so the editor links through it. the project must be on the allowlist, or the route is an open redirector. - optional: smtp digests, webhook notifications, review bots backed by an openai-compatible endpoint.
- implementation feedback runs in the same bounded poll loop. separate modules collect github evidence, validate model proposals and queue them; the poller writes each one into its note as a suggestion through the same editor mutation path the review bots use, and tags an approved spec back into review. source hashes and canonical repo blobs identify each analysis. shared project toggles pause automation independently of personal settings.
mcp/is a separate, optional process that runs on a developer's or an agent's machine, not in the deployment: it indexes the checkout it starts in with tree-sitter, reads the board's public api, and answers a coding agent's questions over the model context protocol within a token budget (context graph for agents).
| property | value | why it is fixed |
|---|---|---|
| replicas | 1 | two pollers can double-open a PR; an advisory lock guards concurrency, not atomicity |
| rollout | replace, never parallel | same reason |
| schema | forward-only DDL at startup | an older image can meet a newer schema |
| shutdown | stops admission, cancels reads/models, drains recorded effects; 25s hard exit | remote writes need reconciliation when their result is uncertain |
trust boundaries¶
- anyone who can edit a note can change spec text, by design. approval works the other way: approvers and quorum come only from the branch-protected target repo, never from the note.
/api/specsis unauthenticated too, and serves whole spec bodies and raw revisions rather than the first paragraph the board page shows. it reads the snapshot after checking current permissions on each request, so it exposes no note hedgedoc would refuse a guest, but it makes the corpus collectable in one request.- the board page is unauthenticated and its search matches note bodies, so it
only ever lists notes hedgedoc itself shows a guest. a spec note set
limited,protectedorprivateis dropped from the board; the poller still tracks it and still publishes its PR. - an approval is the board's own record, not the note's
approved-bylist: the editor's button sends a signed GitHub identity, note, action and saved-text hash. the board checks the login againstroles.ymland compares the current text under the same row lock that records its approval snapshot. quorum and theReviewed-bytrailer both rest on that record, so a name typed into the note by anyone neither opens a PR nor earns a trailer. a commenter is credited from hedgedoc's per-character authorship, a thread signature their own session wrote. branch protection on the target repo remains the control that decides what merges. - the board is the only writer to github and its credentials never leave the pod. it opens a spec PR with the owner's own oauth token where hedgedoc already holds one with push rights, and falls back to its own. the editor's login asks for no repo scope, so for anyone who logged in since that change the fallback is the norm and the PR is the board's.
- review bot api keys live in
spec_board_botsin plaintext, managed from/botsby the accounts inBOARD_ADMINS. - published
/s/views strip every criticmarkup comment, resolved or not. - the mcp server holds no credential and sends nothing to the board beyond
the unauthenticated
GETs above, unless it is given a bot token. with one, it can also write comments and suggestions to the notes in that bot's projects, and nothing else (writing as a bot). the board keeps only the token's hash. what it reads from the checkout it hands to whichever agent runs it, so it belongs on the machine that already has the checkout, not on a shared host.
what a deployment has to provide¶
- postgres 13+, one database shared by both services
- two hostnames with TLS, one per service
- a read-write-once volume for uploads
- credentials per service (bootstrap)
nothing here requires kubernetes. the reference deployment runs on openshift and its manifests are public in specdoc-infra, including the constraints that are specific to it: a single node, hand-made hostPath volumes, and backups on the same disk as the database.