Files
Nguyen Minh Phuc c537073c21
ci / dockerfile (push) Has been cancelled
ci / types (push) Has been cancelled
ci / lint (push) Has been cancelled
ci / security (push) Has been cancelled
ci / chart (push) Has been cancelled
ci / image (api) (push) Has been cancelled
ci / image (reconciler) (push) Has been cancelled
ci / image (worker) (push) Has been cancelled
ci / integration (push) Has been cancelled
ci / unit (push) Has been cancelled
ci / bump (push) Has been cancelled
deps: everything to latest stable
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.
2026-07-19 09:39:38 +00:00

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:

  1. The claim query is one statement. repo/tasks.py::_CLAIM_SQL. Splitting the UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED) into select-then-update creates a double-provision race that testing will not catch.
  2. complete() and fail() check locked_by. That check is a repro-confirmed bug fix, with regression tests in tests/integration/test_tasks.py. Removing it lets a worker whose lease expired clobber a task another worker now owns.
  3. domain/ has no I/O and no async. CI greps for both.
  4. Migrations never run at app startup. They are a Helm pre-upgrade,pre-install hook.

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=Trueteam 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 <= cap alone passes on a fully serial worker.
  • Markers: slow (>1s or hits real Upstash), e2e (needs a cluster). make test runs 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 containerd belongs to a Kubernetes kubelet; installing docker.io would evict the runtime and take down every pod. You cannot docker build here. Verify Dockerfiles by inspection and hadolint.
  • 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. .env is gitignored.
  • Infra changes go through Terraform/Ansible, never kubectl edit. The Gitea runner's config lives in oci-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.