Python 3.12 -> 3.14, postgres 16 -> 18, uv 0.5.11 -> 0.11.29, trivy 0.58.1 -> 0.72.0, gitleaks 8.21.2 -> 8.30.1, yq 4.44.6 -> 4.53.3, and every action re-pinned to the SHA of its latest tag (checkout v7, setup-uv v8, buildx v4, login v4, hadolint v3.3.0). helm stays 3.21.3: already current for 3.x, and helm 4 is a breaking change, not a CVE fix. trivy mattered most. A vulnerability scanner fourteen minor versions behind is the one stale pin that hides all the others. ruff target-version is deliberately py313 while the runtime is 3.14. It controls the syntax the formatter may emit, and at py314 it rewrites 'except (A, B):' into PEP 758's unparenthesized form — which reads exactly like Python 2's 'except E, name:' and is a hard SyntaxError below 3.14. No semantic gain, real readability cost, in a repo meant to be read. Verified on 3.14: ruff, ruff format, mypy --strict, 166 tests, helm lint, bandit, pip-audit. The digest guard still rejects placeholder digests. Risk carried knowingly: the bumped actions run on node24. If act_runner only provides node20, every job fails at action startup and this commit is the revert.
7.0 KiB
AGENTS.md — working in svcforge-reference
Rules for any AI agent (Claude Code, Cursor, Codex, Copilot, Gemini CLI) editing this repo. Read this before your first edit.
What this repo is, and the one rule that follows from it
This is a reference implementation: a complete, verified build of the system that
../learn-python/ teaches Phuc to build himself. It exists to be compared against, not
copied from.
THE RULE: never propose copying domain/, repo/, services/worker/, or the claim
query into learn-python/. Those four are the course. If asked to "help with Module 4",
help him find his own bug; do not paste repo/tasks.py. Scaffolding — pyproject.toml,
Dockerfiles, CI, the chart — is fair game to copy, because struggling with hatchling
teaches nothing.
learn-python/AGENTS.md has its own, stricter rules. They still apply over there.
Before you change anything
Read ARCHITECTURE.md. Every design choice here was paid for by a specific failure mode, and the comments say which. A change that removes a comment's subject usually removes a guard.
Four things that look like over-engineering and are not. Do not "simplify" them:
- The claim query is one statement.
repo/tasks.py::_CLAIM_SQL. Splitting theUPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED)into select-then-update creates a double-provision race that testing will not catch. complete()andfail()checklocked_by. That check is a repro-confirmed bug fix, with regression tests intests/integration/test_tasks.py. Removing it lets a worker whose lease expired clobber a task another worker now owns.domain/has no I/O and noasync. CI greps for both.- Migrations never run at app startup. They are a Helm
pre-upgrade,pre-installhook.
Conventions
Layers. transport → domain ← repo/adapters. domain/ imports nothing from the other
three. repo/ knows SQL and not HTTP. adapters/ owns every subprocess and network call.
Python 3.14 at runtime, but ruff's target-version is pinned to py313 so the
formatter never emits 3.14-only syntax. Full annotations everywhere including tests.
X | None, StrEnum, Final, PEP 695 type aliases. Keep except (A, B):
parenthesized — PEP 758's unparenthesized form reads exactly like Python 2's
except E, name:, which means something else.
Errors. SvcforgeError in svcforge_core/errors.py is the base. New exception types
inherit from it, plus a stdlib base when the behaviour matters (HelmError(SvcforgeError, RuntimeError)).
Logging. obs.get_logger(__name__), structlog kwargs. Never stdlib logging directly,
and never extra={...} — the ProcessorFormatter bridge silently discards it. Never use
message or event as a field name; both are reserved and raise.
SQL. Raw psycopg3, no ORM. Parameters, never f-strings. Rows come back as dicts
(DictPool in repo/db.py); annotate with that alias rather than a bare
AsyncConnectionPool, which mypy resolves to tuple rows.
Subprocess. create_subprocess_exec with start_new_session=True. Never shell=True —
team is tenant input that reaches a helm release name. Timeouts kill the process group.
Protocols. A Protocol needs two implementations (a real one and a fake) or it should
be a concrete class.
Datetimes. Always aware, always UTC. Domain functions take now as a parameter;
only adapters hold a Clock.
Pinning. Everything by digest: base images, tool images, GitHub Actions by SHA.
A SHA-pinned third-party action still resolves its own dependencies by mutable tag —
that is how trivy-action broke. Prefer running tools directly from a digest-pinned image.
Writing style
Plain declaratives. No back-and-forth phrasing, which means none of:
- Rhetorical setup-knockdowns: "That is not a nicety. It is the whole design."
- not-X-but-Y pivots used for rhythm: "It is not a lock, but a lease."
- Throat-clearing: "It is worth noting that", "The key insight here", "Here's the thing".
- Rhetorical questions you then answer.
- Dramatic fragments for rhythm: "Not slow. Not fast. Meaningless."
Write the claim directly. Keep the why — it is the most valuable content in this repo — and drop the scaffolding around it. Factual contrasts that carry information stay ("LEFT join, not inner"; "202, never 201").
Testing
- Never mock the database. Integration tests run against real Postgres. Each pytest
process clones its own database (
tests/integration/conftest.py). - Fakes, not mocks, for helm/kubectl/Redis/clock.
tests/fakes.py. - A test that cannot fail is worse than no test. Before adding one, ask what change
would make it red. Bound assertions on both sides where it matters:
max_concurrent <= capalone passes on a fully serial worker. - Markers:
slow(>1s or hits real Upstash),e2e(needs a cluster).make testruns neither.
export SVCFORGE_TEST_DSN="postgresql://svcforge:svcforge@127.0.0.1:5432/svcforge_test"
make lint # ruff + ruff format + mypy --strict
make test # unit
make integration # needs the DSN above
make ci # every gate CI runs, same commands
Environment facts that will bite you
- This host has no Docker daemon, and must not get one. Its
containerdbelongs to a Kubernetes kubelet; installingdocker.iowould evict the runtime and take down every pod. You cannotdocker buildhere. Verify Dockerfiles by inspection andhadolint. - Postgres is local (apt, v16) for tests. testcontainers is the CI path.
- Redis is real Upstash on a free tier: 500K commands/month. Redis-hitting tests are
marked
slow. Do not write loops that spend thousands of commands; use the fakes. - Secrets live in
~/.config/svcforge/secrets.env, chmod 600, outside every repo. Never echo, log, commit, or paste a DSN or token..envis gitignored. - Infra changes go through Terraform/Ansible, never
kubectl edit. The Gitea runner's config lives inoci-k8s/k8s/roles/addons/tasks/main.yml.
CI/CD
.gitea/workflows/ci.yaml. Gates in order: ruff → mypy → unit+coverage → migrate+integration
→ bandit/gitleaks/pip-audit → hadolint → helm lint/template → build → trivy → push by digest
→ bump chart digests → ArgoCD syncs.
- CI holds no kubeconfig and must never hold one. Its last act is a git commit.
- Build once, promote by digest. The image is scanned before it is pushed.
- Every gate blocks. If a gate is failing, fix the cause. Do not add an ignore, lower a threshold, or mark it advisory. A CVE finding from trivy is real: bump the pinned version.
RUNBOOK.md has the setup steps and the traps that cost real time.
When you are unsure
Say so, and check. This repo's value is that its claims were executed, not asserted — the race conditions have reproductions, the timeout kill has a negative control, the coverage gate was caught measuring 0%. Guessing and sounding confident is the one failure mode that makes it worthless.