Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# AGENTS.md

Guidance for AI coding agents (and humans who want the short version) working
in this repo. This follows the tool-agnostic [AGENTS.md](https://agents.md)
convention: any agent operating here should read this file first.

If you only opened a nested folder, stop and read this file from the
repo root. `apps/web/AGENTS.md` and `CLAUDE.md` point here on purpose.

## Build/test/lint

These are the authoritative commands — they match what CI runs in
`.github/workflows/ci.yml` exactly. Node 22 is used across the board
(`.nvmrc` pins it, so `nvm use` picks it up); run
the suites on 22 — a sync/scorer suite was green on Node 25 and red on 22 in
CI (#256, an unref'd `AbortSignal.timeout` timer).
`apps/web/package.json` says the same thing as a range, `engines.node:
"^22.12.0"` (vitest 5's floor on the 22 line), on purpose: `.nvmrc` and CI's
`node-version: 22` resolve to the latest 22.x on each run and Dependabot
refreshes the image digest weekly, so an exact pin there would be stale after
the first refresh and nothing would move it. The package range, `.nvmrc` and
CI target the 22 line and take its latest patch; the container stays on its
digest-pinned patch until the weekly Dependabot refresh merges.

**sync** (the score poller):

```sh
cd sync && npm ci && npm test
```

**scorer** (judge + leaderboard engine):

```sh
cd scorer && npm ci && npm test
node tools/vacuous-sweep.mjs      # the vacuous-pass gate — must report 0
cd .. && ./scripts/acceptance-scorer.sh   # repo root — the script lives in scripts/
```

**app** (contestant web app):

```sh
cd apps/web
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm lint
corepack pnpm test
```

The grading Lua scripts (`SUBMIT_SCRIPT`, `GRADE_SCRIPT`, `AWARD_SCRIPT`) are
the scoring authority, and `src/lib/__tests__/*.lua.upstash.test.ts` execute
them against a real Redis behind srh; the other `*.upstash.test.ts` suites
next to them (`admin-store`, `hint-store`, `team-store`, `attachments-store`
and more) do the same for the settings, reveal, team and attachment scripts.
Every `src/lib/__tests__/*.upstash.test.ts` suite gates through
`live-redis.ts` — count them by that glob, not from a list here: they skip
locally without `UPSTASH_REDIS_REST_URL`/`UPSTASH_REDIS_REST_TOKEN`, and CI
brings the two containers up and sets `CTF_LUA_SUITES_REQUIRED=1` so a skip
fails the job. If you touch any of those scripts, run them (the `docker run`
lines are in `ci.yml`'s "Grading Lua" step, then `corepack pnpm exec vitest
run upstash --no-file-parallelism` — serial because `admin-store` and
`hint-store` share the fixed `ctf:admin:settings` hash, and `pnpm exec`, NOT
`pnpm test -- upstash`, which forwards the `--` to vitest, drops the filter
and runs every file) — the mocked grade suites pin only what the stores hand
the script, not what it does.

CI also runs a production build (`corepack pnpm build`, with dummy
`BETTER_AUTH_SECRET`/`BETTER_AUTH_URL`) and `./scripts/acceptance-app.sh`
after the test step — run those too if you touched build-affecting config.
Between the two, CI asserts `apps/web/.next/server/app/index.html` does
**not** exist: `/` must never be statically prerendered, or the module nav
(resolved through a build-time-unreachable Redis read) freezes at build time.
Check it after any build you run here.

**quiz-only / classic-only / ai-only** (single-module compose bring-ups):

```sh
./scripts/acceptance-quiz-only.sh
./scripts/acceptance-classic-only.sh
./scripts/acceptance-ai-only.sh
```

**shell / setup** (provisioning and automation scripts):

```sh
shellcheck scripts/*.sh scripts/lib/*.sh scripts/dev-stack setup/*.sh scorer/entrypoint.sh sync/docker-entrypoint.sh \
  deploy/fly/deploy.sh deploy/fly/render-compose.sh deploy/aws-terraform/deploy.sh
shellcheck -s sh --exclude=SC2034 scorer/entrypoints/*.sh
bats setup/test/
bats deploy/fly/test/
bats deploy/aws-terraform/test/
bats scripts/test/
```

(The second `shellcheck` lints `scorer/entrypoints/*.sh` as POSIX `sh`
fragments: they are sourced by `entrypoint.sh`, never run standalone.)

**smoke** (full stack):

```sh
./scripts/smoke.sh
```

**stock-scores-zero / patched-scores-right** (real-target scoring gates):

```sh
./scripts/acceptance-target.sh <target> <stock-image|none>   # stock app scores 0/N
./scripts/acceptance-patched.sh <target> <challenge-id>       # patched fork scores exactly that one
```

Not in `ci.yml`: these are the two heavy workflows named under "CI" below,
one matrix row per target or reference patch, and the rows are the example
invocations. Each boots a real upstream image (minutes per row), so run only
the rows for what you touched. Each workflow's `paths:` filter is the
authority on "what" (read it; the two differ slightly). In summary: rubrics
under `scorer/rubric.owasp/`, the judge-path sources in `scorer/src/`, the
scorer Dockerfile, `package.json` and lockfile, the entrypoints, the gate
scripts and `scripts/lib/acceptance-lib.sh`, `patches/`, and the workflow
files themselves.

**registries** (target lists must agree; no Docker):

```sh
node scripts/check-module-registries.mjs
```

The wrapped commands are also exposed as `make <target>` from the repo root
(`make help` lists them). Not wrapped, deliberately: the live Lua/upstash
suite (needs redis + srh), the production build and `acceptance-app.sh`,
and the two real-target scoring gates — run those from the commands above.
The Makefile does not add a full-repo `test` target on purpose.

## CI

`.github/workflows/ci.yml` has a `changes` job that path-filters which areas
a PR touches, and every other job `needs: changes` and is gated on that
job's output — a job for an untouched area is *skipped* (which still
satisfies a required check), not run needlessly. A push to `main` (i.e.
post-merge) always sets every area `true`, so `main` gets the full run as a
safety net regardless of what the merged PR touched.

The `vacuous` job runs `scorer/tools/vacuous-sweep.mjs`, which points every
target at a stub that is UP but USELESS and fails if any challenge still
passes. It needs no Docker — the stub is an in-process HTTP server — so it is
minutes of plain Node rather than a container bring-up. It was wired in only
once the count reached 0/321: a gate adopted while findings remain is a gate
somebody has to disable.

Two heavier workflows, `stock-scores-zero.yml` and `patched-scores-right.yml`,
are scoped with their own `paths:` filters to judge-relevant scorer inputs
(rubrics, judge/exec/probe/catalogue source, the scorer Dockerfile and
entrypoints, their own acceptance script, and `patches/`) rather than the
full `scorer/` tree — they run real target containers, so they're reserved
for changes that could actually move the score.

The `docs` job builds the Pages site with the action `pages.yml` publishes
with (that workflow runs only on push to `main`), then fails on any relative
`.md` href in the built HTML and any internal link that does not resolve.
The one that bites: `jekyll-relative-links` cannot rewrite a link whose TEXT
wraps across two source lines, so it ships a literal `.md` href that renders
fine on GitHub and 404s on the site — two shipped before the check existed.
Keep link text on one line; link outside `docs/` with an absolute `https://`
URL, never `../`. `codeql.yml` is a stock JavaScript/TypeScript scan on PRs
and weekly, with no repo-side config to keep in step.

The `registries` job compares the duplicated target lists and needs no
install.

**Every PR must also pass CodeRabbit review before merge, not just the CI
jobs above.** CodeRabbit runs automatically on each PR (`.coderabbit.yaml`
tunes it); treat its findings as a required gate. Resolve every actionable
comment — apply the fix, or reply on the thread with the reason it does not
apply and mark it resolved — and leave no unresolved actionable thread on a
PR you intend to merge. A finding you disagree with is answered, not
ignored: CodeRabbit is fallible and a reasoned decline is a valid outcome,
but the decline has to be on the record. Its pre-merge checks
(secrecy boundary, fail-open/closed direction, unpinned dependencies,
breaking-change docs, secrets in logs, behavior changes ship a test) encode
invariants this repo has been burned by; a warning from one is a prompt to
look, not a formality.

Two more workflows run on every PR (#483). `changelog.yml` fails a PR that
changes `apps/`, `sync/`, `scorer/`, `setup/` or `deploy/` without touching
`CHANGELOG.md` (test-only and `.md` paths are exempt; the rules are in
`scripts/check-changelog.sh`, run locally as
`scripts/check-changelog.sh origin/main HEAD`). The `no-changelog` label
skips it, and Dependabot PRs are skipped. `labeler.yml` applies labels by
path from `.github/labeler.yml`. It runs on `pull_request_target` so it can
label fork PRs, which is safe only because it never checks out or runs PR
code. Keep it that way.

**Always WAIT for CodeRabbit's re-review after every push, and check its
comments — do not treat a PR as done on green CI alone.** CodeRabbit
re-reviews each new commit, and a fix (or a fix to a fix) routinely draws a
fresh finding: the branch is only clean when the *latest* commit has been
reviewed and every actionable thread on it is resolved. So after each push,
wait for the CodeRabbit check to report **Review completed** for that
commit, then read what it left, before you call the PR ready or move on.
"CI is green" is not "CodeRabbit is done" — the two are independent gates and
CodeRabbit lands second.

**Talking to CodeRabbit is a rationed, incremental conversation — one
`@coderabbitai review` per pushed batch, and let it resolve its own threads.**
`.coderabbit.yaml` turns on `auto_review` and `auto_incremental_review`, so
CodeRabbit is *configured* to review every push to an eligible pull request —
one that is not a draft, targets `main`, and has no ignored title keyword
(`[WIP]`, `[skip-review]`, `DO NOT REVIEW`, the rest of the list under
`auto_review.ignore_title_keywords`); a draft or a `[WIP]` title is skipped by
design and needs no nudge until it is eligible. But this is a public repo
under 10 stars on the free OSS plan, where even an eligible push's automatic
pass is routinely skipped (the check reads `Review skipped: manual review
required for this OSS repository`) or rate-limited. So after a push to an
eligible PR, read the CodeRabbit check before you say anything to it: if a
pass is running or has completed for that commit, do not trigger another — a
manual `review` on top of an automatic one is a duplicate that burns the
allowance; only when it reads that `Review skipped` status do you trigger the
review, with exactly one top-level `@coderabbitai review` comment. One more
reason a push can go unreviewed: `auto_pause_after_reviewed_commits` is unset
in `.coderabbit.yaml`, so CodeRabbit's default applies and automatic reviews
pause after five reviewed commits on a PR (`0` would disable the pause; the
organization-level setting may change the number) — after that point every
further push needs the same single `@coderabbitai review`. A review,
however it started, is *incremental* — it reads only
the commits pushed since its last pass, re-verifies its open threads, and
resolves the ones the push addressed; no reply is needed on those. Every
push and every `review`/`full review` command consumes one review from an
hourly OSS allowance (1–10 per developer, scoped per repository), so batch
the fixes for a round into one push and never poll on a timer: after a
`Review rate limited` reply, post `@coderabbitai rate limit`, which answers
with when the next review is available — parse that wait, schedule exactly
one reminder for that moment (not a retry loop), and let the session go
idle until it fires; when it does, post exactly one `@coderabbitai review`
and stop. Reply
*inside* a thread only for a finding it left open that is actually fixed
(name the commit and the file:line so its re-verify has something to check)
or to decline with a reason — the exception, per the paragraph above. Two
commands short-circuit the gate and are off-limits here: `@coderabbitai
approve` submits its approval past its own pre-merge checks, and
`@coderabbitai resolve` closes every thread it owns regardless of whether
anything changed — the latter is at most a final sweep *after* its re-review
left nothing substantive. For a rapid multi-push session, `@coderabbitai
pause` first, then `resume` plus one `review` at the end. Use `@coderabbitai
full review` instead of `review` when a rebase or squash rewrote commits it
had already reviewed, or when a push reshaped the PR's overall logic enough
that a commit-by-commit pass would miss the whole — it costs the same one
review from the allowance but re-reads the entire PR. Reference:
docs.coderabbit.ai/reference/review-commands and /management/plans.

## Conventions & gotchas

These are real failure modes this project has hit — treat them as rules, not
suggestions.

- **Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`,
  `chore:`, `ci:`, ...). No AI attribution — no "Generated with", no
  `Co-authored-by:` trailers for an AI tool or agent.
- **Comments explain the present, not the past, and not the code.** A comment
  earns its place by saying what the code cannot: why this shape, what must
  stay true, what breaks if it changes, where a security or fail-closed
  boundary sits. Three things are not that (#505 — the pre-v0.7.0 audit found
  about a quarter of comment lines were): **history** — "previously", "used
  to", "renamed from", "no longer", "added in #N", a bare `(issue #N)`. That
  belongs to the commit that made the change and, once it is a decision, to
  an ADR in `docs/decisions.md`, where it stays true instead of rotting next
  to code it no longer describes. **Change restatement** — "now returns ..."
  narrating a diff the reader can see. **Narration** — restating what the
  next line plainly does. Pointers to *current* facts stay (key layouts,
  invariants, `see <file>`, an ADR number). Directive comments are not
  comments: never delete a shebang, `// eslint-disable…`, `# shellcheck
  disable…` or `// @ts-expect-error`. `scripts/check-comment-policy.mjs` (run
  by `bats scripts/test/`) holds the pre-v0.7.0 audit's top 20 files to the
  first two; narration is a reviewer's call.
- **Bash (`setup/`, `scripts/`) must be bash-3.2/macOS compatible.** No
  `jq` or `python` on the provisioning path (`gh api --jq ...` is fine —
  that's `gh`'s own built-in JSON filtering, not a `jq` dependency). Quote
  your expansions. **Avoid `A && B || C`** — CI's shellcheck flags this as
  SC2015 (the `C` branch also runs if `B` fails); use `if ...; then ...; fi`
  instead. A bare `grep -q` assertion under `set -e` dies with no output —
  `acceptance-app.sh` and `acceptance-quiz-only.sh` both did in CI (#257);
  every assertion names what was missing before it exits.
- **bats assertions must be the test's last statement to gate pass/fail.** A
  `[[ ... ]]` conditional, or a `! ... | grep ...` pipeline, that is *not*
  the final statement in a `@test` block does **not** fail the test on a bad
  result — bash's `!` prefix combined with a non-last compound command is
  errexit-exempt, so the shell just keeps going. Make the decisive check the
  last thing in the test, and prefer a form that actually exits nonzero on
  failure: single-bracket `[ ... ]`, `[ -z "$(... | grep -F ...)" ]`, or a
  trailing `grep -qx ...`.
- **`ctf-setup.sh --dry-run` must make zero `gh`/`docker` calls.** Every
  provisioning step must be idempotent (check-then-skip if already done).
  Every `check_step` must **fail closed** — a `gh` API error or nonzero exit
  must never be interpreted as "the step is already satisfied."
- **Scorer stock-scores-zero invariant.** A stock, unpatched target must
  score 0 on every challenge. Watch for vacuous passes: a test that "blocks"
  an exploit only because the app wasn't actually up/reachable yet looks
  like a pass but proves nothing.
- **There is no config file any more: `.env` bootstraps, `/admin` runs the
  event.** The old event config file and its base64 build-arg bake were
  deleted by #386 (ADR 55) —
  no image takes a config build-arg, so bring the box up as `docker compose
  --profile secdev --profile app up -d --build` (drop `--profile secdev` on an
  event with no `SCORE_IMAGE`). Three `.env` keys are read at RUNTIME and
  nothing falls back to a baked default: `ADMIN_LOGINS` (empty 403s everyone at `/admin` —
  fail-closed, `bootstrap-env.ts`), `GITHUB_ORG` (empty means no fork links
  on `/challenges`, and `sync` refuses to start at all, logging
  `ctf-sync: GITHUB_ORG is not set`), and **`SCORE_IMAGE`, which is how a box
  says whether it runs Secure Development** — non-empty means the scorer and
  sync containers exist and SD is the one module enabled before an organizer
  switches others on; empty means the SD toggle is refused and `ctf-setup.sh`
  skips every fork/mirror/poll step. Everything else (the event's identity,
  which modules run, which targets) is a runtime `/admin` setting.
- **Compose profiles follow `SCORE_IMAGE`.** `app` is always on.
  `secure-development`'s two services both carry `["secdev"]` and nothing
  else — poll is the one score transport (#377, ADR 56), so the scorer and the
  poller are always brought up together. Whoever brings the
  stack up adds `--profile secdev` **iff `SCORE_IMAGE` is non-empty**, which
  is the one place "does this event run Secure Development" is answered and
  never a second knob — `scripts/dev-stack` and `deploy/fly/render-compose.sh`
  both derive it that way (Fly always has one; `deploy.sh` requires it). A
  quiz-only event boots with `--profile app` alone — it has no scorer image to
  pull, and the compose fallback is a private upstream one. So: never give a
  secure-development service the default (profile-less) treatment, and never
  add a `depends_on` from `app` to a profiled service — that drags it into
  every `up` and re-breaks the quiz-only boot.
- **Every compose service must name its network.** `docker-compose.yml`
  assigns each service to `frontend` or `backend` explicitly (only `srh` is on
  both — that split is what keeps `app` from reaching `redis:6379`; see ADR
  41). Compose's rule is that a service declaring NO `networks:` key joins
  `default` — a **third** network, isolated from both — so an override file
  that adds a service without one produces a container nothing else can
  resolve. This already broke `smoke.sh` once: `mock-github` is defined only
  in `docker-compose.smoke.yml`, inherited no network, and `sync` failed every
  poll with nothing but `fetch failed` to go on.
- **`REDIS_PASSWORD` is required, not optional.** Compose reads it with
  `${REDIS_PASSWORD:?}`, so anything that brings the stack up — a script, a
  test, `docker compose config` — must set it or fail at interpolation. Note
  `config`'s failure is easy to swallow: `acceptance-quiz-only.sh` pipes
  stderr to `/dev/null`, where a missing value would have turned into an empty
  service list and a vacuously passing comparison.
- **The score-comment marker is trust-authoritative — it must only ever come
  from the judge's own output, never from the PR checkout.** The sync poller
  ingests any `<!-- ctf-score: {json} -->` marker in a `github-actions[bot]`
  comment as real points. The judge writes its report to `CTF_OUT_DIR` (a dir
  OUTSIDE the PR checkout), and the workflow's final-comment step reads it only
  from there and only when the scorer step succeeded. Never post
  checkout-derived content as the bot, and never gate that post on
  `if: always()` without an outcome check — either reopens the score-forge.
- **The pause/schedule contract lives in THREE readers — change them in
  lockstep.** `effectivePaused`/`outsideScoringWindow` (freeze + scheduled
  scoring window) is implemented independently in
  `apps/web/src/lib/schedule-window.ts` (re-exported through
  `admin-store.ts`), `scorer/src/store.js`, and `sync/src/redis.js`;
  registration windows in `team-store.ts`. They read the same `ctf:admin:settings` fields and must
  agree. The scoring window goes through `outsideScoringWindow`, where a
  start is **required**: an empty or unparseable `scoringStartsAt` means the
  event is not launched and nothing scores (#464, ADR 59). The registration
  window keeps `outsideWindow`, where an absent bound means open.
  `pausedAt(t)` — whether the manual freeze covered an instant, from
  `pausedSince` and the `ctf:admin:pauses` interval log that admin-store's
  settings script writes atomically with `paused` — is the fourth: the
  scorer judges a Secure Development score at its comment's time with it,
  and the window too (ADR 67). Each rule has a shared corpus in
  `test/fixtures/` (`scoring-window-corpus.json`, `window-corpus.json`,
  `pause-interval-corpus.json`) that all three suites run. Manual-freeze
  reads fail **open** (a Redis blip must not drop live submissions); keep
  that.
- **`upstashPipeline` in `apps/web` does not throw on a per-command error.**
  It returns `{ result?, error? }` positionally; a caller that reads
  `.result` without checking `.error` turns `NOAUTH`/`WRONGTYPE` into a
  default. `getAdminSettings` served "not paused" with baked caps and no log
  line (#215); classic's `importBundle` read a failed categories `GET` as
  "none yet" and wrote that emptiness back over the box's list (#261). Check
  `error` and throw so the caller's fail direction applies and says so. The
  `sync` and `scorer` clients throw for you (#256); the app's does not.
- **Logins join case-insensitively, everywhere.** The scorer records the PR
  author's spelling, the app stores the session's, and GitHub logins are
  unique only case-insensitively — so every login join lowercases both sides
  (`module-contributions`, `team-standings`, `hint-penalties`, `admin-auth`).
  Two verbatim joins made one contestant's hints free and showed a scoring
  teammate at 0 pts (#216).
- **Diagrams are hand-authored animated SVGs sharing one style.** Every one
  lives in `docs/assets/diagrams/<name>.svg` and is embedded with a plain
  `<img>` — never a `mermaid` code fence, which cannot animate and cannot be
  reused between the README and the Pages site. They share a contract: a
  `:root` custom-property palette with a `@media (prefers-color-scheme: dark)`
  pair, an opaque `var(--bg)` card painted behind everything, CSS
  `@keyframes` animation only (`.dgm-flow`/`.dgm-ants` marching ants,
  `.dgm-gate` staggered pulse), and a `@media (prefers-reduced-motion:
  reduce)` block that turns all of it off. The palette has to be
  self-contained: an `<img>`-embedded SVG is isolated from the host page's
  CSS, so it cannot inherit the site's variables — and `prefers-color-scheme`
  there follows the OS, not the page, which is why each file paints its own
  card rather than sitting transparent on someone else's background. Each
  file's `<desc>` is the source of the embedding page's `alt` text; change
  one and change the other.
- **Do not commit `docs/superpowers/`, `docs/REVIEW.md` or
  `docs/hygiene-audit.md`.** They're gitignored planning/spec/plan scratch
  space and local audit reports, not shipped documentation — and `docs/` is
  the Jekyll root, so anything tracked there lands on the Pages site.

## Repo layout

- `apps/web/` — vendored Next.js contestant app (auth, teams, leaderboard,
  admin panel). `pnpm`.
- `scorer/` — judge + leaderboard scoring engine. Plain Node.js, `node:test`.
- `sync/` — poll service feeding the leaderboard from score comments. Plain
  Node.js, `node:test`.
- `setup/` — `ctf-setup.sh` and event provisioning. Bash, `bats`.
- `caddy/` — `Caddyfile.poll`, the only Caddyfile, mounted at a constant path
  by `docker-compose.yml`. It proxies to `app:3000` and gives the box **no**
  inbound scoring surface at all. There is no `/score` route to add: push
  ingest was removed in v0.6 (#377, ADR 56), taking `Caddyfile.push`, the
  `push` compose profile, the `SCORE_INGEST` key and the judge's
  `SCORE_API`/`SCORE_TOKEN` hook with it. A `.env` carried over from a push
  event still boots — nothing reads the key (v0.7 also dropped the `doctor`
  notice that named it, #503).
- `patches/` — `<target>/<challenge-id>.patch`, one reference fix per
  challenge; the input to `acceptance-patched.sh`. `git`-format diffs against
  the source the script pins by commit; `patches/README.md` is the contract.
- `test/fixtures/` — `mock-github.mjs` and `mock-scorer.mjs`, the stand-ins
  `docker-compose.smoke.yml` builds for `smoke.sh`; and the shared window
  corpora (`window-corpus.json`, `scoring-window-corpus.json`,
  `pause-interval-corpus.json`) that the app, scorer and sync suites all run
  (the three-reader rule above); and
  `dotenv-corpus.env` (and `dotenv-corpus-rejected.env`, the values it must
  refuse), which `scripts/test/dotenv.bats` runs, with a differential
  against `docker compose config` itself, through
  `scripts/lib/dotenv.sh`, the one `.env` reader every shell entry point
  sources (`scripts/lib/redact.sh` is the deploy scripts' one dry-run
  redactor, the same way).
- `deploy/` — optional cloud deploy modules: `aws-terraform/` (ECS Fargate +
  ElastiCache + ALB; `docs/aws.md`) and `fly/` (one Fly machine running the
  rendered compose file; `docs/fly.md`; its scripts and bats suite run under
  the `shell` job). `aws-terraform/` is CI-validated by
  `.github/workflows/terraform.yml` (fmt + validate + test, never apply).
  **`terraform validate` does NOT inspect rendered output** — the container
  definitions are an opaque JSON string to it, so image references, the
  srh-to-ElastiCache wiring and secret-vs-value all pass validate wrong.
  `deploy/aws-terraform/stack.tftest.hcl` (`command = plan`, mocked
  providers) is what reads them; a task-definition change needs an assertion
  there, not just a passing validate. `deploy/aws-terraform/test/aws.bats`
  covers the other blind spot, `deploy.sh` (the image build and push, and the
  tag it hands Terraform), with stubbed `docker`/`aws`/`terraform`.
- `docs/` — documentation site, published via GitHub Pages.

## Where to look

- [`docs/architecture.md`](docs/architecture.md) — how the stack fits
  together: diagram, score data flow, security model, testing strategy.
- [`docs/modules.md`](docs/modules.md) — the module contract: the boundary
  between the platform and a challenge module.
- [`docs/scorer.md`](docs/scorer.md) — the scorer engine: serve + judge
  modes, both rubric grammars, authoring and building rubrics.
- [`docs/hosting.md`](docs/hosting.md) — standing the kit up: prerequisites,
  how scores reach the box, OAuth app, the `.env` bootstrap.
- [`docs/operations.md`](docs/operations.md) — running the event once it is
  up: admin panel and runtime overrides, teams, the event archive, per-module
  runbooks, pre-event verification, dev-stack, the pre-launch lock.
- [`docs/ai-module.md`](docs/ai-module.md) — the `ai` module's contract with
  the external challenge site: launch tokens, solve reporting, errors, keys.
- [`docs/troubleshooting.md`](docs/troubleshooting.md) — symptom-ordered
  runbook for a misbehaving stack: the `REDIS_PASSWORD` refusal, `/admin`
  403s from a config-less build, `fetch failed` between services.
- [`docs/decisions.md`](docs/decisions.md) — numbered ADRs recording why the
  kit is built this way instead of the alternatives.
- [`docs/reviewing.md`](docs/reviewing.md) — the review guideline: the
  invariants a PR review must verify (secrecy boundary, Lua authority,
  fail directions, anti-vacuous tests) and the ADR-settled choices not to
  re-flag. `.coderabbit.yaml` is its machine-enforced subset.