This is a solo-developer project, but no change reaches players by going straight from an idea to production. Every change moves through the same pipeline below — Derek opens it and Derek closes it, with the Architect, Developer, Qcoder, and Tech Writer stages running in between. For who holds each role, see Meet the team.
Nine stages, one order
Gold nodes are Derek himself; the rest are role sessions. The gold text inside each box is the exact command that starts it.
Read it like a snake: row one runs left to right, drops down, row two runs right to left, drops down, row three runs left to right into Production — numbered 1 through 9 in the order work actually moves. A dashed gold arrow is a kick-back: a later stage sends work backward because it found something wrong with an earlier one — an ambiguous spec, a diff that doesn't match what was promised, or a defect found in SIT.
Stage by stage
Before a Developer writes a line of code, the spec states the properties the change must preserve as invariants — e.g. SPARK_QUEUE_V2_CONTRACT.md's rule that nothing outside can open a connection into the Spark, and that it never gets access to players' emails or phone numbers. Violating one is a kick-back, not a trade-off to weigh.
The standing rule: happy path with real assertions, every guard tested under the wrong condition, and at least one real failure case — not decorative coverage. tests/test_jobs_v2.py's test_claim_next_job_concurrent_exactly_one_wins is the reference case: 8 copies of the code race for the same job, and exactly one must win.
Once tests are green, the Developer runs an independent review script (tools/review_with_qcoder.py, itself shipped and passed through this same pipeline at commit 9152c97) against every changed Python file. Each file gets its own model call that sees only the file's current content and the changed lines — never the reasoning behind the change. It runs locally, before anything is pushed, and looks for:
Anything High severity or worse fails the run automatically.
A Developer's report describes what they did; it isn't a verdict on its own. The Architect reads the real diff and independently verifies the specific things the report claims — for example, confirming a report's claim that a network hop through mcp_client had been removed by searching the codebase for any remaining use of it — before recording a verdict in BUILD_BACKLOG.md.
Five automated checks, defined in .github/workflows/deploy.yml, fire on every push to any of three branches — main, sit, and prod, the three copies of the code this project moves through. The job that actually deploys only runs on a push to prod (if: github.ref == 'refs/heads/prod'), and reaching prod means passing through SIT first via scripts/promote.sh (see below), not a straight line from a main push.
secret-scan: git ls-files -z | xargs -0 python3 scripts/scan_secrets.py
test: pytest -q
lint: ruff check .
security-lint: bandit -c pyproject.toml -r app utils games history current scripts tools migrations mcp_server.py spark_worker.py queue_poller_test.py
dependency-audit: pip-audit -r requirements.web.txt
deploy:
needs: [secret-scan, test, lint, security-lint, dependency-audit] # <- won't run unless every job above succeeded
If any job fails, deploy never runs. Full detail on the three security-related gates is on Docs — Security Measures. See Deploy for the full push-to-production sequence.
Keeping this page honest
Every stage above keeps code accurate. This loop keeps pages like this one accurate as the code they describe changes.
flowchart LR
B3["Developer
flags a doc-worthy
change in their report"]
D3["Architect
dispatches a docs spec
in the same review pass"]
TW["Tech writer
updates the page,
runs the self-review loop"]
SHIP(["/about or /admin/docs
page ships, accurate again"])
B3 -->|"report names the stale claim"| D3
D3 -->|"spec written same pass,
not just noted for later"| TW
TW --> SHIP
style B3 fill:transparent,stroke:#b8953a,stroke-width:1px,stroke-dasharray: 3 2
style D3 fill:transparent,stroke:#b8953a,stroke-width:1px,stroke-dasharray: 3 2
specs/archive/DOCS_ARCHITECTURE_SHOWCASE.md shipped the /about/ai page, a follow-up was flagged rather than left to go stale: its "currently being built" line about the worker's progress-reporting API would go stale once Spark Queue v2 shipped. It did; the fix was dispatched the same review pass as specs/archive/DOCS_VISUAL_FIX_ADDENDUM.md.
Between main and prod
In Derek's own words: "I am the business… I want to test in parallel to the developers. I feel like we step on each other's toes." SIT gives him an isolated stack — a full copy of the app, running for him alone — to test at his own pace, separate from active development, before any change reaches a real player.
main first, where the five gate jobs run — nothing is released to players from there. Once a change is green, Derek promotes it to sit: the SIT stack rebuilds on his own hardware, the Spark, so he can use it like a real player would. Only once he's satisfied does he promote SIT's own tip to prod, the one move that triggers deploy.yml's deploy job and reaches players.
One script is the only door either move goes through:
scripts/promote.sh sit [sha] [--up] # send a tested commit to SIT for review;
# --up also rebuilds the stack on the Spark over SSH
scripts/promote.sh prod [sha] # release to players — only sit's current tip, never a new build
Both subcommands call require_checks_green before doing anything else, and the move to prod goes further: it refuses any commit that isn't exactly sit's current tip. Both checks stop the script cold (exit 1) before git push ever runs, reading check results through the GitHub CLI (gh).
require_checks_green counts as green"Checks" means the six jobs in deploy.yml — the five gate jobs above, plus deploy itself, which is always skipped on a non-prod ref by design. require_checks_green asks GitHub directly, via gh api, for the status of every check against the commit:
| What GitHub reports for a check | What that means |
|---|---|
completed, concluding success, skipped or neutral | Cleared. A job skipped by design is not a failure. |
completed, concluding anything else | Stopped: "N check(s) actually failed." |
Any status that isn't completed | Stopped: "N check(s) still running." |
| No checks reported for the sha at all | Stopped: "no checks have reported yet." |
If gh errors out, or a row can't be parsed, the answer is still refuse, never proceed.
SIT runs on the Spark — Derek's own hardware, reachable only across his private Tailscale network — as a fully separate compose project (docker-compose.sit.yml, project tourney-sit) with its own MySQL and its own named Docker volume, isolated from both dev and prod. It publishes on ${SIT_BIND_IP}:5100, supplied from .env.sit and never 0.0.0.0, and its database publishes on 127.0.0.1:3308 — reachable only from the Spark itself. It runs under a dedicated low-privilege sit account, so a problem inside SIT has nowhere further to go. The access-control detail behind that (rootless Docker, what the sit account can and cannot reach) is Docs — Security Measures's own story to tell.
main commit via promote.sh sit --up, running under the low-privilege sit account. Isolation is demonstrated, not assumed: from the Spark itself, the loopback address on port 5100 refuses the connection while the tailnet address returns 200, and MySQL answers on the Spark's own loopback only — acceptance items 1–3 are met.
Secure session-cookie flag would otherwise block login here for no good reason. SIT's own .env.sit sets SESSION_COOKIE_SECURE=0, and the stack was recreated rather than restarted, since a plain restart doesn't re-read that file. The live response headers confirm it: the session cookie comes back with HttpOnly and SameSite=Lax and no Secure flag, so the browser returns it over http:// and the next request arrives authenticated. Production still sends an HTTPS-only cookie exactly as it did before — app/config.py makes SESSION_COOKIE_SECURE an explicit setting that defaults to that production behaviour, so the control became more explicit, not weaker (specs/archive/FIX_SIT_SECURE_COOKIE_BLOCKS_ADMIN.md).
SIT holds real tournament data, entered through its own admin screens rather than scripts/sit_seed.sh (which exists and is deliberately unused). That exercises the real data-loading routes and stored procedures a live tournament uses, and avoids duplicating the dev database wholesale into a second environment. Its Current Standings page shows "The Tournament Hasn't Started Yet" rather than a leaderboard — that's the same _entries_live() gate dev and production show before tip-off (app/blueprints/public.py), not a sign of missing data.
specs/archive/PARITY_DEV_MTLS.md, specs/archive/PARITY_SIT_MTLS.md) — see app/config.py and app/blueprints/admin.py for where the flags are read and enforced. Network isolation is verified at the level that matters: SIT's web app binds to its own tailnet address, never 0.0.0.0, so nothing outside the tailnet can reach it — confirmed both by a real refused connection from the Spark's loopback and by the bind address itself, observed directly on the box.
More detail lives here
This page is about the pipeline a change moves through. Credential and data-access specifics live on their own pages:
/admin/docs/security plus /admin/docs/credentials — what each credential can and can't reach, and the rotation procedure for each./admin/docs/spark-risk — a risk brief on the Spark environment's access posture. All three live behind the admin login, since they document access boundaries in more detail than makes sense to publish openly.spark_poller database credential is due for rotation (a documented, exercised procedure), and admin authentication is a single shared password with no per-user accounts and no TOTP/MFA yet.
1 of 6