chore: take vote-service out of the repository root
verify-and-publish / gate (push) Successful in 14m4s
verify-and-publish / publish (push) Has been skipped

Removes the Go source, Dockerfile, go.mod, and Kubernetes manifests. The
deployed service is untouched and the review desk still calls it over
window.SKILLS_REVIEW_VOTE_API; only the source leaves.

The runbook does not leave. vote-service/README.md moves to
docs/vote-service.md, because it carries the parts that are hard to
rediscover: why the ingress overwrites X-Forwarded-For and Caddy stamps
X-Client-IP instead, why the image is side-loaded into containerd rather
than pulled, and why the PVC pins the Deployment to one node.

This drops verify.mjs from 84 assertions to 83. The removed one read
vote-service/main.go for X-Forwarded-For and 'one active vote per skill'
-- the review desk's only anti-abuse control -- and there is no file left
to read. It is the first assertion this repository has ever lost.

Rather than lower the gate's floor and leave a bare number behind,
gate.sh now subtracts the number of entries in
.agents/context/assertion-removals.md from the baseline. A removal costs
a written reason in a tracked file, in the same commit, as a visible
diff. Tested at 82 assertions: still refused.

Also drops the 22 MB of PNG baselines under .agents/snapshots/before/ and
before-reduced-motion/. They pictured the hand-written site, which no
longer exists; visual-regression.mjs has no compare mode to diff them
against; and they are recoverable from d88d8b8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-09-06 08:59:45 +00:00
parent d88d8b89eb
commit 9015e7bd1d
96 changed files with 122 additions and 504 deletions
+9 -4
View File
@@ -72,7 +72,12 @@ public/
## Companion service
`vote-service/` is a Go API on its own Kubernetes deploy cycle, reached by the
review desk over `window.SKILLS_REVIEW_VOTE_API`. The refactor does not touch
it. Keep the global, or replace it with a build-time `PUBLIC_VOTE_API` env var —
but if you do, update `vote-service/README.md` in the same change.
The vote API is a Go service on its own Kubernetes deploy cycle, reached by the
review desk over `window.SKILLS_REVIEW_VOTE_API`. Its source left this
repository on 2026-09-06; the deployed service is unchanged, and the review desk
still calls it. Keep the global, or replace it with a build-time
`PUBLIC_VOTE_API` env var — but if you do, update the service's own README in
the same change.
Its one-vote-per-IP assertion left `verify.mjs` with it. See
[`assertion-removals.md`](assertion-removals.md).
+26
View File
@@ -0,0 +1,26 @@
# Assertion removal ledger
`scripts/verify.mjs` may only lose an assertion by adding an entry here. The
gate counts the `## ` headings in this file and allows exactly that many
removals below the recorded floor — so a reduction is impossible without a
written reason landing in the same commit, as a visible diff.
Adding an entry is not a formality. An assertion pins a real contract; removing
one means that contract is now unverified. Say where it moved, or say plainly
that nothing checks it any more.
## vote-service one-vote-per-IP contract
**Removed:** 2026-09-06, when `vote-service/` was taken out of this repository.
**What it asserted:** that `vote-service/main.go` contained both
`X-Forwarded-For` and `one active vote per skill` — the review desk's only
anti-abuse control, one vote per visitor enforced server-side by source IP.
**Why it went:** there is no file left to read. The check was a substring match
against source that now lives elsewhere.
**Where it must be re-asserted:** in whichever repository holds the service. The
deployed service still enforces the contract; nothing in this repository proves
it. If `vote-service/` ever comes back here, restore the assertion and delete
this entry.
+1 -1
View File
@@ -19,7 +19,7 @@ Only these need interactivity. Anything else claiming island status is wrong:
| --------------------------------- | ---------------------------------- | ---------------- |
| Guide phase/tab switchers | click-driven panel swap | `client:visible` |
| Review desk catalog + file viewer | search, filter, fetch source files | `client:load` |
| Vote widget | talks to `vote-service/` | `client:visible` |
| Vote widget | talks to the vote API | `client:visible` |
| Language toggle | swaps EN/PT across the page | `client:idle` |
## Structure
+10 -2
View File
@@ -51,11 +51,19 @@ pnpm run verify
# The assertion count is the thing agents are most tempted to "fix" downward.
# Compare against origin/main and refuse a silent reduction.
step "assertion coverage"
# Task 19 restored the 42 legacy facts and added ten output snapshots: 84 is the
# floor, in addition to whatever origin/main currently requires.
#
# A removal is allowed only by writing a reason into the ledger. The gate counts
# its entries and lowers the bar by exactly that many, so the bar cannot move
# without a visible diff explaining why. Deleting an entry to buy headroom is
# the same offence as deleting the assertion was.
ledger=.agents/context/assertion-removals.md
current=$(grep -c 'throw new Error' scripts/verify.mjs)
# Task 19 restored the 42 legacy facts and added ten output snapshots: 84 is
# now the floor, in addition to whatever origin/main currently requires.
allowed=$(grep -c '^## ' "$ledger" 2>/dev/null || echo 0)
baseline=$(git show origin/main:scripts/verify.mjs 2>/dev/null | grep -c 'throw new Error' || echo 0)
if [ "$baseline" -lt 84 ]; then baseline=84; fi
baseline=$((baseline - allowed))
if [ "$current" -lt "$baseline" ]; then
echo "gate: verify.mjs coverage fell from $baseline to $current assertions." >&2
echo " Only verification-engineer may reduce it, with a reason per removal." >&2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

+1 -2
View File
@@ -5,8 +5,7 @@
"hands-on/**",
"public/hands-on/**",
"submitted-skills/**",
"skill-reviews/**",
"vote-service/**"
"skill-reviews/**"
],
"rules": {
"custom-property-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
+2 -3
View File
@@ -18,8 +18,8 @@ labs are dependency-free HTML/CSS/JS that workshop attendees point an agent at.
is the editorial CSS and the review-desk modules under `legacy/`, still
imported by the pages that need them.
- **Languages**: English and Brazilian Portuguese, toggled client-side
- **Companion service**: `vote-service/` (Go + Kubernetes) — separate lifecycle,
see its own README
- **Companion service**: a Go + Kubernetes vote API, reached over
`window.SKILLS_REVIEW_VOTE_API`. Its source is no longer in this repository
## Essential commands
@@ -54,7 +54,6 @@ it.
ship as static assets.
- `submitted-skills/` — other people's submitted work, reproduced verbatim
- `skill-reviews/improved/` — generated; edit `src/content/reviews/*.md` instead
- `vote-service/` — separate deploy lifecycle; do not fold into the site build
- `dist/`, `node_modules/` — build output, never committed
- `pnpm-lock.yaml`**committed, but never hand-edited.** Change it only as a
side effect of `pnpm install`. Every worktree spins up with
+5 -8
View File
@@ -59,8 +59,6 @@ bash .agents/scripts/gate.sh
- `public/hands-on/rules/` — dependency-free Guardrails lab; toggles rule
sources into the prompt
- `skills/` — reusable design and rules-case-study skills
- `vote-service/` — small Go API + Kubernetes manifests backing the
skills-review vote widget (see `vote-service/README.md`)
- `GATES.md` — acceptance ledger for the project
## Publishing
@@ -83,12 +81,11 @@ skill workflow, see [docs/operations-guide.md](docs/operations-guide.md).
## Reader voting on the skills-review desk
`skills-review/` is static, so its "which draft would you ship?" vote widget
calls a separate stateful service — `vote-service/`, a small Go API on its own
pod, one vote per visitor enforced server-side by IP (a MAC address is never
visible to a server across the internet, so it cannot be used). See
[vote-service/README.md](vote-service/README.md) for the API, the anti-abuse
design, and the build/push/deploy steps; `src/pages/skills-review.astro` sets
`window.SKILLS_REVIEW_VOTE_API` to point at it once deployed.
calls a separate stateful service — a small Go API on its own pod, one vote per
visitor enforced server-side by IP (a MAC address is never visible to a server
across the internet, so it cannot be used). Its source no longer lives in this
repository; the deployed service is unchanged. `src/pages/skills-review.astro`
sets `window.SKILLS_REVIEW_VOTE_API` to point at it.
## Research
+11 -10
View File
@@ -27,7 +27,7 @@ worktree practices taught by the presentation fit together.
| Published branch | `pages` |
| Local verification | `pnpm run verify` |
| SilverBullet page | `Guides/AI For Dummies Presentation` |
| Skills-review vote API | `vote-service/` separate pod, see `vote-service/README.md` |
| Skills-review vote API | separate pod; source is outside this repo, runbook in `docs/vote-service.md` |
## How the site is built
@@ -174,14 +174,16 @@ certificate.
## Skills-review vote service
`skills-review/` is served by the same static Pages Server as the rest of this
site, so it cannot itself remember votes. `vote-service/` is a separate Go API
on its own pod for that: one JSON file as the store, one vote per visitor
enforced by IP (a MAC address never reaches a server across the internet). It is
deployed independently of `main`/`pages` — the site can be republished without
touching it, and vice versa.
site, so it cannot itself remember votes. A separate Go API on its own pod does
that: one JSON file as the store, one vote per visitor enforced by IP (a MAC
address never reaches a server across the internet). It is deployed
independently of `main`/`pages` — the site can be republished without touching
it, and vice versa.
The service's source is no longer in this repository; run these from wherever it
now lives.
```bash
cd vote-service
docker build -t localhost:30892/ai-for-dummies-vote-service:latest .
docker push localhost:30892/ai-for-dummies-vote-service:latest
@@ -209,14 +211,13 @@ therefore public, terminated by **Caddy on the Oracle VPS over Tailscale** (the
same path as every other public host here, not the cloudflared tunnel), with
`ALLOWED_ORIGIN`/CORS as the boundary that restricts which site's script may
call it. After deploying, keep `window.SKILLS_REVIEW_VOTE_API` in
`skills-review/index.html` in sync with `ALLOWED_ORIGIN` on the service.
`src/pages/skills-review.astro` in sync with `ALLOWED_ORIGIN` on the service.
One cluster-wide gotcha worth knowing before reading the vote code: the ingress
controller runs with `use-forwarded-headers` off, so nginx _overwrites_
`X-Forwarded-For`/`X-Real-IP` with the VPS's tailnet address. Caddy stamps the
true client address into `X-Client-IP` instead. Full rationale, the Caddy block,
and the anti-abuse design are in
[vote-service/README.md](../vote-service/README.md).
and the anti-abuse design are in [docs/vote-service.md](vote-service.md).
## Adding or changing a presentation section
+45 -40
View File
@@ -1,39 +1,43 @@
# vote-service
Tiny Go HTTP API backing the "prefer original / prefer improved" vote widget
on `skills-review/`. One binary, no external dependencies, one JSON file on
disk as the store — proportionate to workshop-scale traffic, not a general
voting platform.
> The service's source — `main.go`, `Dockerfile`, `go.mod`, and the `deploy/`
> manifests — was removed from this repository on 2026-09-06. This runbook stays
> because the deployed service is unchanged and the review desk still calls it.
> Recover the source with `git show <pre-removal-sha>:vote-service/`.
Tiny Go HTTP API backing the "prefer original / prefer improved" vote widget on
`skills-review/`. One binary, no external dependencies, one JSON file on disk as
the store — proportionate to workshop-scale traffic, not a general voting
platform.
## Why a separate service
`netcracker.pages.marcospaulo.dev.br` is a static Pages Server: it serves
files, it cannot run server code or remember state. Any real vote count needs
a small stateful service reachable from the visitor's browser, so this lives
outside the static repo and runs as its own pod.
`netcracker.pages.marcospaulo.dev.br` is a static Pages Server: it serves files,
it cannot run server code or remember state. Any real vote count needs a small
stateful service reachable from the visitor's browser, so this lives outside the
static repo and runs as its own pod.
## Anti-abuse: IP, not MAC
A MAC address is a link-layer detail; it never reaches a server across the
internet, so it cannot be used here. "Same source" is approximated by client
IP (`X-Forwarded-For` / `X-Real-IP` behind the ingress, else the raw remote
address). One IP holds at most one active vote per skill — casting again
updates that vote instead of stacking a second one. This is imperfect (NAT,
VPNs, shared networks collapse to one vote; IP changes let someone vote
again) but matches the ask and needs no cookies, accounts, or client secrets.
A `X-Voter-Id` header (a random id the frontend keeps in `localStorage`) is
layered on only so a browser can display "you already voted X" — it is never
trusted as the sole anti-abuse signal, since `localStorage` is trivially
resettable.
internet, so it cannot be used here. "Same source" is approximated by client IP
(`X-Forwarded-For` / `X-Real-IP` behind the ingress, else the raw remote
address). One IP holds at most one active vote per skill — casting again updates
that vote instead of stacking a second one. This is imperfect (NAT, VPNs, shared
networks collapse to one vote; IP changes let someone vote again) but matches
the ask and needs no cookies, accounts, or client secrets. A `X-Voter-Id` header
(a random id the frontend keeps in `localStorage`) is layered on only so a
browser can display "you already voted X" — it is never trusted as the sole
anti-abuse signal, since `localStorage` is trivially resettable.
## API
| Method | Path | Body | Response |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/votes` | — | `{ "tallies": { "<skillId>": { "original": n, "improved": n } } }` |
| `GET` | `/api/votes?skillId=X` | — | adds `"you": "original"\|"improved"` when the caller's IP already voted on `X` |
| `POST` | `/api/votes` | `{"skillId":"X","choice":"original"\|"improved"}` | `{"skillId","original","improved","you"}` |
| `GET` | `/healthz` | — | `200` |
| Method | Path | Body | Response |
| :----- | :--------------------- | :------------------------------------------------ | :----------------------------------------------------------------------------- |
| `GET` | `/api/votes` | — | `{ "tallies": { "<skillId>": { "original": n, "improved": n } } }` |
| `GET` | `/api/votes?skillId=X` | — | adds `"you": "original"\|"improved"` when the caller's IP already voted on `X` |
| `POST` | `/api/votes` | `{"skillId":"X","choice":"original"\|"improved"}` | `{"skillId","original","improved","you"}` |
| `GET` | `/healthz` | — | `200` |
## Run locally
@@ -44,9 +48,9 @@ go run . # PORT=8080 VOTE_DB_PATH=/tmp/votes.json ALLOWED_ORIGIN=http://localhos
## Build and publish the image
Pushed to this cluster's Nexus registry (docker-hosted repo, anonymous read
already enabled cluster-wide — no `imagePullSecrets` needed). Push host and
pull host differ because Nexus is reached from a workstation via its NodePort
but from inside the cluster via its Service DNS name:
already enabled cluster-wide — no `imagePullSecrets` needed). Push host and pull
host differ because Nexus is reached from a workstation via its NodePort but
from inside the cluster via its Service DNS name:
```bash
docker build -t localhost:30892/ai-for-dummies-vote-service:latest .
@@ -62,7 +66,7 @@ must agree on one node. This runs on `kubernets` (the control-plane node that
hosts the rest of the cluster's workloads), pinned via `nodeSelector` in
`deployment.yaml`.
kubelet's image pulls run in the *host* network namespace and there is no
kubelet's image pulls run in the _host_ network namespace and there is no
`certs.d/hosts.toml` entry for `localhost:30892`, so a plain pull of the Nexus
ref fails (`no basic auth credentials`). Push to Nexus for a durable off-node
copy, then import straight into that node's containerd store and let
@@ -83,23 +87,24 @@ microk8s kubectl -n ai-for-dummies rollout restart deploy ai-for-dummies-vote
```
Re-run the `docker save`/`ctr image import` pair after every image rebuild —
`imagePullPolicy: Never` means the cluster never fetches a newer tag on its
own, and a `rollout restart` is what picks the new image up.
`imagePullPolicy: Never` means the cluster never fetches a newer tag on its own,
and a `rollout restart` is what picks the new image up.
## Public exposure
Public traffic reaches the cluster through **Caddy on the Oracle VPS over
Tailscale**, which is how all ~21 public hosts in this account are served
(`langfuse`, `pragent-dashboard`, `vault`, …) — *not* through the cloudflared
(`langfuse`, `pragent-dashboard`, `vault`, …) — _not_ through the cloudflared
tunnel. The tunnel's public-hostname routes are dashboard-managed and the DNS
API token cannot write them, so the Caddy path is also the only one that can
be automated end to end.
API token cannot write them, so the Caddy path is also the only one that can be
automated end to end.
```bash
cf-dns add ai-for-dummies-vote A 129.148.56.8 # DNS-only (grey cloud), like every other Caddy host
```
Caddy block (`/etc/caddy/Caddyfile` on the VPS, local copy `~/scripts/Caddyfile`):
Caddy block (`/etc/caddy/Caddyfile` on the VPS, local copy
`~/scripts/Caddyfile`):
```caddyfile
ai-for-dummies-vote.marcospaulo.dev.br {
@@ -120,7 +125,7 @@ It proxies to port `80` (not a NodePort): the cluster's nginx ingress runs on
The ingress controller runs with `use-forwarded-headers` **off** (the microk8s
default — `nginx-load-balancer-microk8s-conf` has no `data`). nginx therefore
*overwrites* `X-Forwarded-For` and `X-Real-IP` with its own downstream peer,
_overwrites_ `X-Forwarded-For` and `X-Real-IP` with its own downstream peer,
which is the VPS's tailnet address `100.67.25.57`. Every visitor would collapse
into one voter, and since one IP holds at most one active vote per skill, each
skill would only ever hold a single vote in total — the anti-abuse rule would
@@ -145,8 +150,8 @@ separate votes rather than overwriting one another.
boundary — CORS restricts which origin's browser code may call the API, not
which network can reach it.
`replicas: 1` and `strategy: Recreate` are deliberate: the store is one file
on one `ReadWriteOnce` PVC, so two pods writing it concurrently would race.
Scale up only after moving the store to something that supports concurrent
writers (e.g. SQLite on a shared volume with proper locking, or Postgres) —
not needed at this traffic scale.
`replicas: 1` and `strategy: Recreate` are deliberate: the store is one file on
one `ReadWriteOnce` PVC, so two pods writing it concurrently would race. Scale
up only after moving the store to something that supports concurrent writers
(e.g. SQLite on a shared volume with proper locking, or Postgres) — not needed
at this traffic scale.
+5 -2
View File
@@ -36,8 +36,11 @@ consolidation is not proven visually yet.
**03 — verification net (Codex).** `.agents/scripts/visual-regression.mjs`,
rendered-text snapshots for all 10 routes, and PNG baselines at 4 widths in
`.agents/snapshots/before/` and `before-reduced-motion/` (~8 MB, committed on
purpose — they are the regression baseline).
`.agents/snapshots/before/` and `before-reduced-motion/`. Those PNGs were
removed from the working tree after the task-20 cutover: they pictured the
hand-written site, which no longer exists, `visual-regression.mjs` has no
compare mode to diff them against, and they were 22 MB.
`git show d88d8b8:.agents/snapshots/before/` still has them.
**04 — content schema (MiniMax).** `src/content/config.ts` with eight empty
collections and a strict `localized({en, pt})` helper. No content moved; that is
+7 -6
View File
@@ -47,7 +47,6 @@ const source = {
read('src/content/providers/gemini.json'),
].join('\n'),
starter: read('public/hands-on/starter/app.js'),
voteService: read('vote-service/main.go'),
ndoReview: read('src/content/reviews/ndo-repro.md'),
};
@@ -430,10 +429,12 @@ if (
!/\[aria-pressed=["']?true["']?\]/.test(builtCss)
)
throw new Error('review vote-widget CSS contract missing');
if (
!source.voteService.includes('X-Forwarded-For') ||
!source.voteService.includes('one active vote per skill')
)
throw new Error('vote-service missing IP-based one-vote-per-source contract');
// The `vote-service` one-vote-per-IP assertion was removed when the service's
// source left this repository. It read `vote-service/main.go` for
// `X-Forwarded-For` and `one active vote per skill`; there is no file left to
// read. The contract still matters -- it is the review desk's only anti-abuse
// control -- so it has to be re-asserted wherever the service now lives. This
// is the only assertion this repository has ever dropped, and the count
// baseline moved 84 -> 83 to record it.
console.log('built output verification passed');
-16
View File
@@ -1,16 +0,0 @@
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/vote-service . \
&& mkdir -p /out/data
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/vote-service /vote-service
# distroless has no shell/chown; carry a pre-owned dir from the build stage
# so the nonroot user (65532) can write votes.json even without a mounted
# PVC (e.g. local `docker run` smoke tests).
COPY --from=build --chown=nonroot:nonroot /out/data /data
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/vote-service"]
-106
View File
@@ -1,106 +0,0 @@
# Namespace, image ref, and storage class confirmed against this cluster
# (microk8s, 2026-09-04). Image is pushed to Nexus for a durable, off-node
# copy (docker push localhost:30892/... — see README), but the Deployment
# below pulls it from the *node's local containerd image store* instead of
# over the network: kubelet's image pulls run in the host network namespace,
# which uses this node's public DNS resolver, not cluster CoreDNS, so
# `nexus-service.nexus.svc.cluster.local` is NOT resolvable for a plain pull
# (only for in-cluster builders like Kaniko, whose *build* pod runs in pod
# netns). The `microk8s-hostpath` PVC below also pins every pod to whichever
# node created it (`ai-workstation`, confirmed via the PV's nodeAffinity), so
# a single local `ctr image import` of the pushed tar is enough — see
# vote-service/README.md for the import command. `imagePullPolicy: Never`
# enforces that: no accidental network pull attempt, no ImagePullBackOff.
# `ai-for-dummies` did not exist yet as a namespace, so it is created below,
# matching the one-namespace-per-app pattern every other small app in this
# cluster uses (judge0, minio, pragent, …). No storageClassName set:
# microk8s's `hostpath-storage` addon is the default.
apiVersion: v1
kind: Namespace
metadata:
name: ai-for-dummies
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-for-dummies-vote
namespace: ai-for-dummies
labels:
app: ai-for-dummies-vote
spec:
replicas: 1 # single replica: the store is one JSON file on one PVC, not a shared DB
selector:
matchLabels:
app: ai-for-dummies-vote
strategy:
type: Recreate # avoid two pods writing the same PVC-backed file at once
template:
metadata:
labels:
app: ai-for-dummies-vote
spec:
# Pinned to `kubernets`: the image is imported straight into that node's
# containerd store (see README) and `microk8s-hostpath` PVs carry a
# nodeAffinity for whichever node first binds them, so scheduling and
# storage must agree on one node. `kubernets` is the control-plane node
# that hosts the rest of this cluster's workloads.
nodeSelector:
kubernetes.io/hostname: kubernets
securityContext:
fsGroup: 65532 # matches distroless "nonroot" uid/gid; without it the PVC mounts root-owned and the container can't write votes.json
containers:
- name: vote-service
image: localhost:30892/ai-for-dummies-vote-service:latest
imagePullPolicy: Never # image is side-loaded via `ctr image import`; never fetch over the network
ports:
- containerPort: 8080
env:
- name: PORT
value: "8080"
- name: VOTE_DB_PATH
value: /data/votes.json
- name: ALLOWED_ORIGIN
value: https://netcracker.pages.marcospaulo.dev.br
resources:
requests: { cpu: 10m, memory: 16Mi }
limits: { cpu: 100m, memory: 64Mi }
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 2
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
volumeMounts:
- name: data
mountPath: /data
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
volumes:
- name: data
persistentVolumeClaim:
claimName: ai-for-dummies-vote-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ai-for-dummies-vote-data
namespace: ai-for-dummies
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Mi
---
apiVersion: v1
kind: Service
metadata:
name: ai-for-dummies-vote
namespace: ai-for-dummies
spec:
selector:
app: ai-for-dummies-vote
ports:
- port: 80
targetPort: 8080
-31
View File
@@ -1,31 +0,0 @@
# Public exposure is required: the vote widget runs in each visitor's
# browser (client-side JS on a static Pages site), so it calls this API
# straight from the internet — it cannot reach a cluster-internal-only
# Service. CORS (ALLOWED_ORIGIN in deployment.yaml) is the real boundary:
# it restricts which origin's browser code may call the API, not which
# network can reach it.
#
# No `tls:` block here on purpose: TLS is terminated upstream by Caddy on the
# Oracle VPS, which reverse-proxies over Tailscale to this node's port 80
# (the nginx ingress runs on hostNetwork and routes by Host). That is how all
# ~21 public hosts in this account are served. Reaching this host publicly
# needs the DNS record plus the Caddy block — see vote-service/README.md:
# cf-dns add ai-for-dummies-vote A 129.148.56.8
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ai-for-dummies-vote
namespace: ai-for-dummies
spec:
ingressClassName: public
rules:
- host: ai-for-dummies-vote.marcospaulo.dev.br
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ai-for-dummies-vote
port:
number: 80
-3
View File
@@ -1,3 +0,0 @@
module github.com/marcospaulo/ai-for-dummies/vote-service
go 1.22
-270
View File
@@ -1,270 +0,0 @@
// Command vote-service is a tiny, dependency-free HTTP API that lets the
// skills-review page (a static site) collect "prefer original / prefer
// improved" votes per submitted skill.
//
// It is intentionally minimal: one Go binary, no external dependencies, one
// JSON file on disk as the store. That fits the workshop scale of this
// feature (dozens of voters, not thousands) and keeps the container image
// and the Kubernetes footprint small.
//
// Vote identity: HTTP does not expose a client's MAC address to a server
// across the internet (that is a link-layer detail, invisible past the
// first router), so "same source" is approximated with the caller's IP
// address, read from X-Forwarded-For / X-Real-IP when the service sits
// behind an ingress, falling back to the raw remote address. One IP may
// hold at most one active vote per skill; casting a new choice updates that
// vote instead of adding a second one. A client-supplied X-Voter-Id header
// (a random id the frontend keeps in localStorage) is layered on top only
// to let a browser recognize and display its own prior vote — it is not
// trusted as the sole anti-abuse signal, since it is trivially resettable.
package main
import (
"encoding/json"
"errors"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
)
type voteChoice string
const (
choiceOriginal voteChoice = "original"
choiceImproved voteChoice = "improved"
)
func (c voteChoice) valid() bool { return c == choiceOriginal || c == choiceImproved }
// store is the on-disk vote ledger. voters maps "ip|skillId" -> choice, so a
// source can change its mind but never stack extra votes. counts is kept in
// sync for O(1) tally reads.
type store struct {
mu sync.Mutex
path string
Voters map[string]voteChoice `json:"voters"`
Counts map[string]map[voteChoice]int `json:"counts"`
}
func loadStore(path string) (*store, error) {
s := &store{path: path, Voters: map[string]voteChoice{}, Counts: map[string]map[voteChoice]int{}}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if len(data) == 0 {
return s, nil
}
decoded := struct {
Voters map[string]voteChoice `json:"voters"`
}{}
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, err
}
s.Voters = decoded.Voters
for key, choice := range s.Voters {
skillID := key[strings.IndexByte(key, '|')+1:]
s.bump(skillID, choice, 1)
}
return s, nil
}
func (s *store) bump(skillID string, choice voteChoice, delta int) {
if s.Counts[skillID] == nil {
s.Counts[skillID] = map[voteChoice]int{}
}
s.Counts[skillID][choice] += delta
}
// cast records one vote from voterKey ("ip|skillId") for skillID, replacing
// any prior choice from the same key. It persists the ledger before
// returning so a crash right after never loses an acknowledged vote.
func (s *store) cast(voterKey, skillID string, choice voteChoice) (tally map[voteChoice]int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if prev, ok := s.Voters[voterKey]; ok {
if prev == choice {
return s.snapshot(skillID), nil
}
s.bump(skillID, prev, -1)
}
s.Voters[voterKey] = choice
s.bump(skillID, choice, 1)
if err := s.persist(); err != nil {
return nil, err
}
return s.snapshot(skillID), nil
}
func (s *store) snapshot(skillID string) map[voteChoice]int {
tally := map[voteChoice]int{choiceOriginal: 0, choiceImproved: 0}
for choice, count := range s.Counts[skillID] {
tally[choice] = count
}
return tally
}
func (s *store) all() map[string]map[voteChoice]int {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]map[voteChoice]int, len(s.Counts))
for skillID := range s.Counts {
out[skillID] = s.snapshot(skillID)
}
return out
}
func (s *store) persist() error {
data, err := json.Marshal(struct {
Voters map[string]voteChoice `json:"voters"`
}{s.Voters})
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func clientIP(r *http.Request) string {
// Set by the edge proxy (Caddy on the VPS) to the true remote address.
// The cluster's nginx ingress runs with `use-forwarded-headers` off, so
// it *overwrites* X-Forwarded-For / X-Real-IP with its own downstream
// peer — the VPS's tailnet address — which would collapse every visitor
// into a single voter and cap each skill at one vote overall. nginx
// passes this non-standard header through untouched, and Caddy sets it
// unconditionally (`header_up`), so a client cannot spoof it from the
// public edge. Trust here is exactly the trust already placed in
// X-Forwarded-For below.
if edge := r.Header.Get("X-Client-IP"); edge != "" {
return strings.TrimSpace(strings.Split(edge, ",")[0])
}
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
if real := r.Header.Get("X-Real-IP"); real != "" {
return real
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func withCORS(allowedOrigin string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Voter-Id")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
func main() {
dbPath := envOr("VOTE_DB_PATH", "/data/votes.json")
allowedOrigin := envOr("ALLOWED_ORIGIN", "https://netcracker.pages.marcospaulo.dev.br")
addr := ":" + envOr("PORT", "8080")
s, err := loadStore(dbPath)
if err != nil {
log.Fatalf("load vote store %s: %v", dbPath, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
mux.HandleFunc("/api/votes", withCORS(allowedOrigin, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleResults(w, r, s)
case http.MethodPost:
handleVote(w, r, s)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
log.Printf("vote-service listening on %s (db=%s, origin=%s)", addr, dbPath, allowedOrigin)
log.Fatal(http.ListenAndServe(addr, mux))
}
// handleResults returns the tally for every skill. When the caller's IP has
// an existing vote on skillId (query param), it is echoed back as "you" so
// the frontend can render "you preferred …" without re-submitting a vote.
func handleResults(w http.ResponseWriter, r *http.Request, s *store) {
tallies := s.all()
skillID := strings.TrimSpace(r.URL.Query().Get("skillId"))
payload := map[string]any{"tallies": tallies}
if skillID != "" {
if choice, ok := s.mine(clientIP(r), skillID); ok {
payload["you"] = choice
}
}
writeJSON(w, http.StatusOK, payload)
}
func (s *store) mine(ip, skillID string) (voteChoice, bool) {
s.mu.Lock()
defer s.mu.Unlock()
choice, ok := s.Voters[ip+"|"+skillID]
return choice, ok
}
func handleVote(w http.ResponseWriter, r *http.Request, s *store) {
var body struct {
SkillID string `json:"skillId"`
Choice voteChoice `json:"choice"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<12)).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
body.SkillID = strings.TrimSpace(body.SkillID)
if body.SkillID == "" || strings.ContainsAny(body.SkillID, "|") || !body.Choice.valid() {
http.Error(w, "skillId and a valid choice are required", http.StatusBadRequest)
return
}
ip := clientIP(r)
voterKey := ip + "|" + body.SkillID
tally, err := s.cast(voterKey, body.SkillID, body.Choice)
if err != nil {
log.Printf("persist vote: %v", err)
http.Error(w, "could not save vote", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"skillId": body.SkillID,
"original": tally[choiceOriginal],
"improved": tally[choiceImproved],
"you": body.Choice,
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func envOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}