Overflow is a cooperative ledger for open-source work: a repository sponsor
offers an issue, an outside contributor closes it through GitHub, and Overflow
records a settled credit transfer with auditable proof. README.md describes
what the ledger records. This file describes how to change it.
Overflow is already running at https://overflow.nitjsefni.eu, and that instance is what this repository exists to serve. If you have not looked at it yet, sign in there first — the ledger, the settlement proofs and the rules page are much easier to reason about from the inside than from a description of them. Using Overflow never requires running your own copy; the setup in this file is a development environment.
Issues and pull requests are welcome. So is the smaller kind of contribution — a report that says "the rules page claims X and the fold code does Y, here is the query" is worth as much as a patch, because everything here is meant to be reproducible from GitHub's own record.
Two things about this repository are unusual enough to be worth reading before
you start. Its .gitignore denies by default, so a new file is invisible to git
until you name it. And its offered: and settled: labels are not workflow
decoration — they are ledger input, and the ledger reads them from the live
issue. Both have their own sections below.
For scripts and agents registering repositories, see Programmatic repository registration in the README for token generation, the request contract, and a complete example.
You may use an LLM or a coding agent to write your contribution. There is no penalty, no separate review queue, and no expectation that you launder its output through a hand rewrite.
Two conditions, and both are about honesty rather than provenance:
-
Disclose the model with a trailer on each commit it authored:
Co-Authored-By: <Model Name> <noreply@example.com>The plain model name — a context-window suffix such as
(1M context)is not part of the name and does not belong in the trailer. One trailer per model that authored the commit. -
Do not submit claims you have not verified. Paste the command and its real output. "Tests pass" without the run is not evidence, and this codebase is an easy one to be confidently wrong about: most of the interesting logic is a fold over GitHub events where ordering, actor identity and a fifteen-minute tolerance decide the outcome, and a plausible reading of the code and its actual behaviour part company quietly.
What follows builds a development environment for working on Overflow's own code. It is not how you use Overflow — that is https://overflow.nitjsefni.eu. A copy you run yourself keeps its own ledger in its own PostgreSQL database, and nothing in this codebase moves balances, settlements or calibration between deployments, so a local instance starts empty and stays private to itself.
Node and pnpm are pinned in package.json. engines names Node 24.17.0 and
pnpm 10.33.0, and packageManager names pnpm@10.33.0, which is what CI
installs through corepack. Use those versions; the lockfile is installed frozen,
so a different pnpm is the first thing that will argue with you.
Copy .env.example to .env and replace every placeholder — every value
in that file is an angle-bracket placeholder, and none of them is a working
default:
DATABASE_URL— a PostgreSQL 17 connection string.AUTH_SECRET— generate withnpx auth secret.AUTH_GITHUB_IDandAUTH_GITHUB_SECRET— a GitHub OAuth application's credentials. Its callback URL is<APP_URL>/api/auth/callback/github.TOKEN_ENCRYPTION_KEY— 32 random bytes as unpadded base64url. This is the AES-256-GCM key for stored OAuth tokens, so it is a real key even locally.APP_URL,GITHUB_WEBHOOK_URL,GITHUB_WEBHOOK_SECRET— the public application URL and the webhook endpoint GitHub must be able to reach over public HTTPS, plus the shared secret. You only need these to exercise the webhook path end to end; the test suite does not.MODERATOR_GITHUB_USER_IDS— comma-separated GitHub account ids granted the moderator role at sign-in (gh api users/<login> --jq .id).
Placeholders only in anything checked in. Never commit OAuth credentials, webhook secrets, database passwords or encryption keys.
The database must be PostgreSQL 17. Point DATABASE_URL at a server you
already run, or start the one docker-compose.yml ships:
docker compose up -d postgres
docker compose psThat service is postgres:17-alpine with database, user and password
overflow / overflow / overflow_local_only, published on loopback only,
and a pg_isready healthcheck, so docker compose ps telling you it is
healthy is the signal to continue. POSTGRES_HOST_BIND widens that binding on
purpose; Compose reads it from your shell or from the same root .env the
application uses, so a value left in that file widens the binding for every
later docker compose up. That password is committed and well known, so any
address other than a loopback one publishes a database with known credentials
to everything that can route to this machine. To reach it from elsewhere,
forward the loopback port over SSH — ssh -L 5432:127.0.0.1:5432 <host> —
instead of widening the bind address. Then install, migrate and run:
pnpm install --frozen-lockfile
pnpm db:migrate
pnpm devpnpm db:migrate runs scripts/migrate.ts, which applies every
db/migrations/NNN_*.sql in sorted order and records each name in a
schema_migrations table. It skips what is already recorded, so running it
twice is safe and running it after a git pull is the habit to build.
Each migration gets its own transaction, together with the
schema_migrations row that records it. The two commit together or not at
all, so no migration is ever half-applied or applied without being recorded.
The run is not one transaction: PostgreSQL refuses to read an enum label that
alter type ... add value added until the adding transaction has committed, so
a migration can only build on the catalogue an earlier one committed.
A failed upgrade therefore keeps every migration before the failure. The
run stops at the migration that raised, and a re-run resumes there rather than
replaying from the start. Fix whatever the failure named and run
pnpm db:migrate again — do not restore a backup on the assumption the run
rolled back, and do not expect a half-migrated database to be an error state in
itself. Trip 014's opening-authority precondition, for instance, and 013
stays applied, having already dropped the users_github_login_key constraint;
that is the intended resting place until the data the precondition names is
corrected.
CI is one workflow with one job. .github/workflows/ci.yml defines verify,
which runs on pushes to main, on pull requests targeting main, and on
manual dispatch. It stands up PostgreSQL 17 as a service, installs the pinned
toolchain, and then runs four commands after applying the migrations. Run the
same five locally, in this order:
pnpm db:migrate
pnpm test --run
pnpm lint
pnpm typecheck
pnpm buildThe order is CI's, and it is the useful one: the migration runs first because the database suites migrate a container of their own and a broken migration should fail before the whole suite does, and the build runs last because it is the slowest and the least likely to tell you something the other three did not.
Use pnpm test --run — the same command CI runs — for anything you are going
to report. --run is what pins a single non-interactive pass regardless of how
your terminal is attached; pnpm test:watch is the watching variant, for use
while you work rather than in a result you paste into a pull request.
Two suites need a container runtime. tests/db/schema.test.ts and
tests/moderation/postgres-store.test.ts each start a real postgres:17-alpine
through testcontainers and run the actual migrations against it. Without Docker
reachable, both fail in beforeAll with
Error: Could not find a working container runtime strategy
and their 49 tests are reported as skipped while the run as a whole exits
nonzero. Read that summary carefully: 49 skipped is not 49 passed, and those
are precisely the tests that pin the migration path, the materialization
invariants the schema enforces, and the moderation state transitions — the parts
most likely to break and the parts a unit test with a stubbed store cannot
notice. If you changed anything under db/, src/lib/db/, src/lib/fold/ or
src/lib/moderation/ and your run says skipped, you have not tested it.
A second workflow, .github/workflows/actionlint.yml, checks the workflows
themselves: actionlint for schema, expression and shell correctness, and zizmor
for workflow security and supply-chain posture, both over
.github/workflows/*.yml. It exists because a broken workflow does not go red,
it silently stops running.
If you edit a workflow, note that tests/api/ci-workflows.test.ts asserts its
contents — the triggers, the permissions block, the concurrency group, the
PostgreSQL 17 service, forty-character commit pins on every action, the pinned
Node version, and the release commands the job must run. That is deliberate: it
makes a quietly weakened gate fail the suite rather than pass unnoticed. It also
means a workflow change is two edits, and the same is true of package.json,
whose pinned versions that test reads.
These three are the reason this file exists. Each of them lets a change look finished and be wrong, with nothing on screen to say so.
.gitignore denies by default. It starts with * and then names back, by hand,
every single file the repository ships. There is no generator: adding a file
means editing that list.
Until you do, the file is untracked and does not appear in git status.
git add on it reports nothing, and the commit lands without it. Nothing fails
on your machine, because the file is right there on your disk — the loss only
exists in everyone else's checkout, which is where it will be found.
Naming a file back takes three lines, not one, because git never descends into a
directory it has already excluded — a ! rule underneath an un-reopened
directory never matches. Reopen the directory, deny its contents again, then
name the files back with a glob scoped to that directory, which is what stops
the pattern reaching into subdirectories you did not mean:
!src/app/rules/
src/app/rules/*
!src/app/rules/*.tsxFor a file at the repository root, one ! line beside the other root entries at
the top of the file is enough, since the root is not excluded by a parent.
Prove it rather than reading the file and assuming. Both of these, on the new path:
git check-ignore -v path/to/new-file.tsx
git status --porcelaingit check-ignore -v prints the last pattern that matched the path, and
that pattern is the whole answer. Before you name a file back you get the
catch-all:
.gitignore:1:* CONTRIBUTING.md
and afterwards you get the negation that rescued it:
.gitignore:6:!CONTRIBUTING.md CONTRIBUTING.md
Read the pattern, not the exit status: both of those exit zero, because a
pattern matched in both cases. Then confirm with git status --porcelain that
the file shows up as untracked. A file still caught by the catch-all is absent
from git status altogether, which is exactly what makes this failure quiet.
tests/db/schema.test.ts asserts the exact list of applied migrations, name by
name, and that each was applied exactly once. Adding
db/migrations/NNN_something.sql therefore fails that suite until the list in
the test names it too. Same commit, both files.
The assertion is not bureaucracy: runMigrations decides what to apply by
reading the directory and diffing against schema_migrations, so nothing else
in the codebase records what the schema is supposed to be. That test is the
record.
src/app/globals.css defines .surface alongside .ledger-card, .issue-card
and .empty-state, and all it supplies is a border and a background. It has no
padding. A new panel that reaches for className="surface" and nothing else
renders with its text flush against the border — legible enough in a screenshot
to be missed, wrong on every viewport.
Give the panel its own class next to .surface and put the spacing there.
.rules-card is the pattern to copy: it sets margin-top, a fluid
padding: clamp(...), and the measure and rhythm of the headings and paragraphs
inside it.
The repository ships one issue template,
.github/ISSUE_TEMPLATE/bug-report.md,
and its section order is fixed. The
title lives in GitHub's own title field — one plain-language line, no
ticket-speak and no trailing punctuation, because it gets copied verbatim into a
pull request's Bugs Discovered list and has to stand alone there.
The rule worth internalising is that Description is observed behaviour only: what is actually wrong, not the mechanism and not the fix. If you have proved a mechanism, it goes under Suggested Fix, clearly marked unverified. This is not pedantry. A description that is really a hypothesis sends the next reader straight to the place the hypothesis points at, and when the hypothesis is wrong — which it often is — the real defect is now harder to find than it was before the report existed.
Expected Behavior and Discovered During are required too; Discovered During cites the pull request, task or session that surfaced the issue, and a session identifier is a fine answer when there is no pull request.
Reproduction Steps is conditional. If the behaviour is not reliably reproducible, delete that section entirely and say so in the Description, rather than writing steps that do not actually trigger it — steps that fail for the reader get the report closed as unreproducible when the defect was real. Environment / Context and Suggested Fix are optional; keep them only when version, configuration or data conditions genuinely matter.
Comment /claim on an open, unassigned issue and
.github/workflows/claim.yml assigns you. You do
not need write access, which is the entire point: GitHub's built-in slash
commands do not include assignment, so without this workflow the people Overflow
calls outside contributors are exactly the people who cannot perform the action
it prices. The assignment is also what reserves the sponsor's credit — available
headroom is settled balance minus the reserve points of open issues assigned to
outside contributors — so a claim is a ledger event, not a courtesy.
The comment body must be exactly the command after trimming whitespace and
carriage returns. "I'll /claim this one" is a sentence and is ignored. The
workflow also ignores pull requests, closed issues, and bot comments, and it
tells you in a reply when an issue is already held by someone else. Read that
reply: it confirms the assignment, or explains why there was none.
/unclaim and /release are the same command under two names. Either removes
your own assignment and nobody else's.
Release an issue you stop working on, and do it before the merge that would close it. The workflow acts on open issues only, so once the issue is closed a stale assignment on it can no longer be removed — and until it is removed, it is still holding reserve points against the sponsor.
This is the convention with real consequences outside the repository, so it gets its own section.
Overflow prices work from labels on the issue. This repository's own catalog is
offered: trivial through offered: deep for the opening catalog, each
carrying comparison and reserve points from 1 through 10, and settled: 1
through settled: 10 for the actual catalog. README.md and the in-product
rules page at src/app/rules/page.tsx state the rules the ledger applies; they
are worth reading in full before you touch a label.
The two consequences to hold on to:
offered:belongs to the repository sponsor, at filing. Opening difficulty is the earliest opening label the sponsor applied before the first assignment. Work has to be priced before it is spoken for, so a label applied after someone has taken the issue cannot set its price — it is not a late correction, it is nothing. Labels from anyone else, including the issue's author, do not price it at all.settled:has a window with two halves. The label must be applied by the repository sponsor between the closing pull request's final commit and its merge, and a nonblank comment from the sponsor must name the label. Both halves are the evidence; a label with no comment, or a comment that does not name the label, settles nothing. A comment edited after the window closes settles nothing either — its current body is not evidence of what was written before the close. Exactly one actual-catalog label may be active — two settle nothing. Labels on the pull request never price anything.
Those orderings are checked with a fifteen-minute tolerance, because they
are a sequence people perform by hand and the order things land in is routinely
off by a little — labelling and assigning in one gh issue create invocation
applies the assignee first, for instance. The tolerance absorbs that. It does
not widen the rule: evidence outside it is still rejected. The settlement
window closes fifteen minutes after merge and cannot be reopened. A settled
label applied an hour later proves nothing about what the reviewer saw. The
earliest qualifying comment at or after the standing label is used, including
when a label is reapplied; if none exists, a comment up to fifteen minutes
before that label can count. The comment must be created by the window close
and must not be edited after it.
Review rounds are frozen at merge. credits = max(0, actual points − distinct review rounds) counts the changes-requested reviews submitted before merge as
they stood when the pull request merged: dismissing one after the merge does
not remove it, and dismissing one before the merge does. A dismissal exactly at
merge also leaves the round counted; no timing tolerance applies to reviews.
A dismissed review counts only if its dismissal history establishes that it
requested changes; missing history or an unknown previous state does not count.
So: labels are never applied to tidy an issue up, and never adjusted because a branch turned out harder or easier than expected. Retitling an issue is housekeeping; relabelling one is editing a ledger. If a price looks wrong, say so in a comment and leave the label alone.
The repository ships .github/PULL_REQUEST_TEMPLATE.md,
and it is the shape to fill in. Summary, Changes and Testing are required and
stay even when the honest answer is "None". Related Issues and Pull Requests is
conditional but always worth checking — closing keywords go there and nowhere
else. Bugs Discovered is a pointer list only, one line per filed issue with the
title copied verbatim and no commentary. Breaking Changes, Follow-ups / Known
Limitations and Dependencies are optional; delete the heading and its comment
when it does not apply. The Footer is required and names every model that
touched the pull request.
Testing means what you actually ran and what it said. For a change to the fold or the schema, that includes confirming the container-backed suites ran rather than skipped.
Small and single-purpose beats large and comprehensive. One logical change per
commit, with a message that says what changed and why the previous behaviour was
wrong. main accepts rebase merges only, so your commits land on main
exactly as you wrote them — the history everyone else reads is the one in your
branch, not a squashed summary of it.
That is also why, if you find a second defect while fixing the first, you should file it rather than fold it in. A commit that fixes two things is a commit that cannot be reverted for one of them.