Skip to content

security: track A hardening (closes #201, closes #202, closes #203, closes #204, closes #205, closes #206, closes #207) - #222

Merged
cofade merged 18 commits into
mainfrom
claude/codebase-audit-plan-dypztx
Aug 18, 2026
Merged

security: track A hardening (closes #201, closes #202, closes #203, closes #204, closes #205, closes #206, closes #207)#222
cofade merged 18 commits into
mainfrom
claude/codebase-audit-plan-dypztx

Conversation

@cofade

@cofade cofade commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

⚠️ Operator prerequisite before merge (for #201): rotate the Discord webhook (Server Settings → Integrations → Webhooks → delete + recreate) and set the new URL as DISCORD_WEBHOOK_URL on the prod host. The committed token stays in git history either way — rotation is the actual fix; this PR just stops shipping it.
Operator step for PM2 hosts (for #204): set HIGHFIVE_ENV=production in the server-side process config for duckdb-service and image-service — without the marker the new boot guard is a no-op (see auth.md → "The secret").

What changed

The complete Security & Data Integrity track of the 2026-07 audit, one commit per issue plus two review-fix commits (the branch also carries the earlier audit roadmap update):

Why

The 2026-07 audit found a live webhook token committed in source, a path traversal on the image delete route, an unbounded unauthenticated upload surface, silent dev-key fallbacks in production Flask, an uncaught-500 write path, and two unbounded in-memory maps. Each issue (#201#207) carries the full background; this PR implements the track as planned, one reviewable commit per issue.

How tested

  • ESP32-CAM native (pio test -e native) — firmware untouched; pio unavailable in the CI container this was built in, runs in repo CI
  • End-to-end (pytest tests/e2e) — docker unavailable in the build container; runs in repo CI (throttle default 30/h clears the seeders' 6-8 rapid uploads with headroom)
  • Backend unit (Node 22 + TS) — 221 passed (28 files); tsc build clean
  • image-service unit (pytest) — 142 passed (Python 3.11 locally; matrix in CI)
  • duckdb-service unit (pytest) — 261 passed
  • Homepage unit (React 19 + Vite) — 193 passed (32 files); production build clean
  • Manual / hardware-in-the-loop verification — not required; no firmware change

make check-citations: 7 OK, 0 problems. Two independent senior-reviewer gate runs (full branch + scoped re-review of fixes): mergeable, no P0/P1; all actionable P2s addressed in the two review commits.

Checklist

  • Tests added or updated to cover the change (~60 new tests across 4 suites, incl. traversal, flood-bound, prod-guard matrix, ordering-contract, and reservation-release cases)
  • Documentation updated where applicable (auth.md, api-reference.md, api-contracts.md, image-upload-flow.md, docker-compose.md, chapter 11 ×2 lessons, glossary)
  • No secrets, credentials, or large binaries committed (check-no-hardcoded-api-keys.sh extended and green)
  • CI is green on this branch — pending (just pushed)
  • Breaking changes called out: modul_id payloads now 400 (no known emitter); dev-stack duckdb host port now loopback-only; uploads over 5 MB rejected; waitlist throttled. Operator prerequisites in the banner above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr


Generated by Claude Code

claude added 11 commits July 19, 2026 15:17
The previous stub pointed at a CLAUDE.md section (In-flight multi-PR
series) that no longer exists. Replace it with the five audit track
tables referencing the 2026-07 backlog (#201-#221) and ordering notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…ices

The DISCORD_WEBHOOK_URL default in duckdb-service/services/discord.py
AND image-service/services/discord.py was a live webhook URL — a bearer
credential shipped to every clone. Default is now empty (= notifications
disabled), the value flows only through the env var (wired in both
compose files), and check-no-hardcoded-api-keys.sh gains a
discord.com/api/webhooks pattern so the class can't recur.

The webhook itself must be rotated by the operator before this merges;
the old URL stays in git history either way. For #201 (scope widened:
the audit issue cited only duckdb-service; image-service carried the
same default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…filename identity

delete_image joined the client-supplied <path:filename> straight into
os.remove — ../ escaped the upload folder (reads were only safe because
send_from_directory contains). New services/paths.py provides
safe_child_path (realpath containment, used by delete + both serve
routes) and sanitize_upload_filename + dedupe_filename: fleet-grammar
names pass byte-identical, hostile names normalize, and colliding names
get a -N suffix instead of silently overwriting (fleet filenames carry
no module identity — same-second captures from two modules used to
clobber each other). The stored name now flows to the DB row, sidecar,
snips, Discord, and response. For #202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…kdb-service in dev

/upload gets a 5 MB MAX_CONTENT_LENGTH ceiling (JSON 413) and a
per-module sliding-window rate guard (services/upload_throttle.py,
default 30/h, env-overridable, tracked-MAC dict bounded against spoof
floods). Over-budget uploads are accepted-and-discarded with a 200,
deliberately not a 429: any non-2xx counts toward the firmware's
5-failure circuit breaker (client.cpp) and would reboot a module
mid-capture-storm, amplifying the storm capture_gate (ADR-024) already
bounds device-side. Dev compose now binds duckdb-service to
127.0.0.1:8002 matching prod — the sole DB writer's unauthenticated
internal endpoints were LAN-reachable from every dev box. For #203.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…production

Both Flask services resolved key-or-dev-fallback silently for their
admin-gated /logs endpoints; prod safety rested entirely on the compose
:? interpolation, which the PM2/bare-metal path doesn't have. New twin
services/prod_guard.py (per-service copy, log_ring precedent) raises at
app import when HIGHFIVE_ENV=production and HIGHFIVE_API_KEY is unset,
blank, or the dev fallback in any casing — mirroring backend/src/auth.ts
edge cases. docker-compose.prod.yml sets the marker for both services;
PM2 hosts set it in server-side process config (documented in auth.md).
Dev behaviour byte-identical. For #204.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…backend's progress fetch

add_progress_for_module raised straight to a 500 on any non-JSON or
schema-violating body — the only write route without the validation
idiom its siblings use. Now: silent JSON parse + ValidationError -> 400.
get_progress returned the entire daily_progress table unconditionally;
it now accepts optional module_id/since/until/limit params (400 on bad
values) with an explicit date-ascending ordering contract, and limit
keeps the most recent rows — the latest-is-last invariant the backend
totalHatches roll-up depends on is now pinned by test and documented.
The backend passes limit=100000 as a safety valve (no behaviour change
today; oldest-dropped-first if it ever bites). For #205.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…serLocation cache

POST /api/waitlist was an unthrottled anonymous relay into the
operator's Discord alert channel — one client could flood it and drown
real silence-watcher alerts. New generic SlidingWindowLimiter
(rateLimit.ts, separate instance + semantics from the login
failure-counter in session.ts) gates it at 3/hour/IP with a 429 the
homepage renders as a translated retry-later message (EN+DE keys).
userLocation's per-IP cache — whose own comment admitted unbounded
growth — now sweeps expired entries and evicts oldest-inserted at a
5000-entry cap. Both limiter and cache bounds are test-pinned,
including the address-diverse-flood cases. For #206.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
Full-tree sweep found no remaining emitter of the legacy typo — the
only in-tree sender (upload_pipeline's _record_progress) emits the
canonical module_id, and the last modul_id fixture (the dev-tools
Postman collection) is corrected in this commit. AliasChoices removed
from ClassificationOutput; a typo'd payload now fails validation with
a clean 400, pinned by the flipped rejection tests. Docs (chapter 11,
api-contracts, glossary, api-reference, upload-flow) updated to
window-closed. For #207.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
…only waitlist budget

Three review-caught fixes on the audit branch: (1) the filename dedupe
was check-then-write and raced under threaded Flask — reserve_filename
now claims the name atomically via O_CREAT|O_EXCL placeholder; (2)
get_progress's limit guard used isdigit(), which admits Unicode
superscripts that int() rejects — a malformed-input 500 reintroduced
through the back door, now try/except with a pinned '³' case; (3) the
waitlist limiter consumed budget before validation and the webhook
check, so three email typos or a Discord outage locked a legitimate
signer out for an hour while providing zero flood protection — budget
is now consumed only by submissions that reach the relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
… fails

reserve_filename claims a name by creating an empty placeholder; a
failed req.image.save (disk full, truncated multipart) previously left
that 0-byte ghost behind, shifting future same-name uploads to -1
suffixes. The reservation is now unlinked on any save failure so the
directory is left exactly as found. Test-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoGDGqYSuTjBCQqgtFDnFr
@cofade
cofade requested a review from schutera July 19, 2026 16:13
…-plan-dypztx

# Conflicts:
#	docs/11-risks-and-technical-debt/README.md
cofade and others added 6 commits August 18, 2026 01:33
…eploy

Senior review of the track-A hardening. The controls are sound; several were
wired to a deployment the fleet and the auto-deploy driver don't use.

P0 -- the dev loopback bind breaks the hardware bench outright. Verified
against the firmware, not the commit message: ESP32-CAM/extra_scripts.py bakes
HF_INIT_URL_DEFAULT = http://<DEV_SERVER_HOST>:8002/new_module into every
LAN-dev build (HF_DEV_BUILD=1 hard-fails without DEV_SERVER_HOST, so it is the
only supported dev firmware path), esp_init.cpp registers against that URL, and
client.cpp's sendHeartbeat reuses it "purely as the carrier of host+port".
Binding dev's 8002 to 127.0.0.1 removes the module's only route in: registration
fails and heartbeats stop, silently. Prod can be loopback-bound because
host-Nginx proxies exactly those two paths -- docker-compose.prod.yml's own
comment says so, and calls them "the only two ESP firmware paths that hit
duckdb-service directly". Dev has no Nginx.

The PR also shipped a doc asserting the opposite ("that flow talks to
image-service and the backend, never to duckdb-service directly"), which is
the failure CLAUDE.md's never-trust-commit-messages rule exists to catch.
Reverted the dev bind, rewrote the doc to state the real dev/prod split and
what to do on an untrusted network, and left prod's loopback bind untouched --
it was already there, so the dev change bought nothing and cost the bench.

P1 -- two controls were no-ops on the deployment that actually ships.
HIGHFIVE_ENV appears only in docker-compose.prod.yml, but scripts/deploy.sh
reloads pm2 apps whose env comes from the ecosystem.config.js template in
production-runbook.md. So require_prod_key() returned at its first `if` and
#204 never fired on the live host; the same omission left DISCORD_WEBHOOK_URL
unset there, which silently disables the ADR-005 silence watcher -- the
operator's primary field-failure signal -- because send_discord_message
degrades to a print(). Added the required env for both Python services to the
runbook with a concrete pm2 snippet, a table of what breaks without each, and
a --update-env verification command.

P1 -- auth.md called /upload "bounded, not authenticated". The guard keys on
the client-supplied, unauthenticated MAC, so it bounds a runaway MODULE and
not a hostile client, which can rotate MACs for a fresh budget each time;
_MAX_TRACKED bounds the dict, not the writes. Corrected to say what is
actually bounded, and filed #224 -- deliberately not "fixed" here, because the
obvious per-IP budget throttles a whole site behind one NAT egress, and a
security fix that breaks ingestion is worse than the bug. Added a test that
plays the actual adversary and asserts the gap, so closing it later fails
loudly rather than leaving a green suite over a hole. (The existing
bounded-dict test performs that exact attack and asserts only the dict stays
small -- it demonstrates the hole while looking like a defence.)

Cross-version bug: routes/progress.py used date.fromisoformat, which widened
in 3.11 to accept 20260719 and 2026-W29-1. With CI spanning 3.10-3.14
(ADR-029) the same query string 400s on 3.10 and 200s on 3.12 -- an API
contract that depends on the interpreter. Now strptime with the documented
format, pinned by a test over all the widened forms.

upload_throttle: added a lock and switched the call site to time.monotonic().
The clock bug is real (an NTP step backwards leaves future-stamped events that
never age out). On the lock I am being explicit that I could NOT reproduce the
race on 3.12 even with setswitchinterval(1e-9) and 16 contending threads; it
stays because the GIL is an implementation detail and the CI matrix already
runs 3.13/3.14, and the test says exactly that rather than pretending to prove
a race it cannot trigger.

Verified: image-service 145/145, duckdb-service 261/261, backend 292/292,
homepage 193/193, shellcheck clean, compose config valid, doc-citation and
hardcoded-key gates green.

Co-Authored-By: WOZCODE <contact@withwoz.com>
….py file

The pre-commit hook invokes bare `ruff` on duckdb-service/ and
image-service/ Python files, but pip installs ruff.exe into Python's
Scripts\ directory, which is not on PATH by default on Windows. So
`py -m ruff --version` works while `ruff --version` does not, and only
Python-touching commits fail — which reads as intermittent. Cost a
debugging cycle in this session; records the fix, the one-off Git Bash
workaround, and why --no-verify is the wrong answer. Also notes the
concurrent prettier SIGKILL as memory pressure rather than a real
prettier failure.

Co-Authored-By: WOZCODE <contact@withwoz.com>
…nippet

Round-2 review of the track-A hardening.

The embarrassing one first: round 1 was about a bind change whose docs said the
opposite of the code, and the revert fixed the compose file and one doc while
leaving auth.md -- the security document's own enumeration of unauthenticated
surfaces -- still asserting "both compose files bind 127.0.0.1:8002". A branch
that contradicted itself two files apart. Rewritten to state the real
asymmetry: loopback in prod because host-Nginx proxies /new_module and
/heartbeat, LAN-published in dev because there is no Nginx and the firmware's
baked HF_INIT_URL_DEFAULT has no other route in, therefore treat a running dev
stack as trusted-LAN-only.

Since a grep would have caught that in two seconds and `make check-citations`
cannot (it proves a citation RESOLVES, not that a sentence is TRUE), this adds
scripts/check-duckdb-bind-claims.sh: prod must stay loopback-bound, dev must
not be, and no doc may claim both. Verified it fails on the exact regression
(re-applied the loopback bind, watched it fail, reverted). Wired into the
Makefile, .husky/pre-push, and CI alongside the sibling gates, and recorded in
ci-gates.md.

Second P0 -- #201 removed a hardcoded webhook (a live credential in a public
repo, correct to remove) and wired DISCORD_WEBHOOK_URL through the compose
files. The previous round documented it for the PM2 path only, so on the
SUPPORTED Docker production path .env.production.example and
production-deployment.md still said nothing about it, and the file that
promises "compose will FAIL FAST if any of these are unset" interpolates this
one as :- rather than :?. Net effect of a security PR: the ADR-005 silence
watcher -- the operator's only signal that a field module went quiet -- goes
dark on deploy with nothing to notice. Both files now say so, with a verify
command.

The pm2 snippet added last round did not work as written. process.env.X inside
ecosystem.config.js is evaluated by the pm2 CLI, whose environment does not
include /var/www/highfive/.env, and neither Python service imports dotenv (the
backend does). Following it verbatim gave HIGHFIVE_ENV=production with no key
-> prod_guard raises at import -> autorestart crash loop on both services.
Now loads dotenv explicitly. Also corrected "pm2 restart ignores edits without
--update-env": --update-env on a NAME refreshes from the invoking shell and
does not re-read the ecosystem file at all -- and since deploy.sh reloads with
--update-env from a shell sourcing .deploy.env, that file can strip these
values on the next tick, so it now carries HIGHFIVE_ENV/HIGHFIVE_API_KEY too.

api-reference.md and the handler comment both claimed a webhook outage does not
consume waitlist budget. allow() runs before the fetch, so it does. Kept the
behaviour (refunding on failure would let a flooder farm unlimited relay
attempts while Discord is down) and fixed both claims to describe it.

Two chapter-11 lessons added, since round 1 and round 2 each produced one and
neither had a durable home: "hardening dev to match prod removed the
compensating mechanism prod has and dev doesn't", and "a control wired into
docker-compose.prod.yml is inert on the host that actually deploys".

P2s: the hardcoded-key gate missed the legacy discordapp.com webhook host --
confirmed by planting one and watching it pass -- now discord(app)?\.com and
confirmed failing on both hosts; the ruff troubleshooting entry no longer
hardcodes one user's profile path; the strptime docstring no longer claims
"strictly" (2026-7-9 is accepted, identically on every matrix Python, which is
the actual point); the runbook's LOG_DIR no longer points at /data, which is
the Docker volume path and does not exist on a bare-metal host.

Verified: duckdb-service 261/261, image-service 145/145, all five repo gates
green including the new one, shellcheck -S info clean, compose config valid.

Co-Authored-By: WOZCODE <contact@withwoz.com>
…tring

Round-3 review. The gate I added last round to stop this class of regression
was itself defeated in one keystroke, which is the finding worth leading with.

The old gate matched the literal `'127.0.0.1:8002:8000'`. Three evasions,
all reproduced here before fixing:
  * switching dev to DOUBLE quotes reintroduced the exact regression and the
    gate printed OK;
  * deleting the dev mapping entirely also printed "LAN-reachable", which was
    then a lie;
  * a quote-style change in PROD made it fail on an unchanged bind.
It was a snapshot test of one sentence sold as a semantic invariant.

Rewritten to ask `docker compose config --format json` for the RESOLVED
host_ip of the 8002 publisher. That is immune to quoting, short-vs-long ports
syntax, and interpolation. Verified against all four shapes: double-quoted
loopback FAILS, long-form `host_ip: 127.0.0.1` FAILS, ports-key-removed FAILS
with a message about the fleet losing its only route in, an invalid ports list
FAILS as a parse error, and the correct tree passes. Also dropped the
doc-sentence arm entirely -- a tripwire keyed to one phrasing costs more to
maintain than it buys, and it would have false-failed on the chapter-11 lesson
that narrates the incident using the same words.

Two script bugs found while testing it: the first version exited 0 with NO
output when compose rejected the file (set -e killed it inside a command
substitution), and it called python3, which does not exist in Git Bash on
Windows -- the same detection ESP32-CAM/build.sh already does for #99.

Self-contradictions the review found, all now fixed:
- api-reference claimed a 503 consumes waitlist budget. The
  DISCORD_WEBHOOK_URL-unset branch returns BEFORE the limiter, so it does not.
  The status list is now derived from where the check actually sits: 400 no,
  503 no, 502 yes.
- The runbook's env table told a bare-metal operator to use LOG_DIR=/data
  while the snippet twelve lines below said "NOT /data - that is the Docker
  volume path". The table row is now correct.
- .deploy.env.example shipped HIGHFIVE_ENV=production beside a BLANK
  HIGHFIVE_API_KEY. Under the precedence that same file documents, an operator
  who filled only the webhook would export production-with-no-key into both
  Flask services and crash-loop them via the guard this PR added. Both lines
  are now commented out with "set these together or neither".
- .env.production.example promised "FAIL FAST if any of these are unset" while
  this PR added a `:-` variable directly below it.

Wire-shape documentation CLAUDE.md's table required and the PR had skipped:
/upload gained a 413 and a SECOND 200 shape carrying none of
mac/battery/filename/classification. Both are now in api-reference.md 2.2 and
api-contracts.md, with the instruction to branch on the presence of `filename`
rather than on the status code, and the reason the throttle is a 200 and must
not be "fixed" to a 429 (it would reboot a storming module via the firmware's
circuit breaker).

Also: Makefile help entry for the new gate.

Verified: all five repo gates green, shellcheck -S info clean, compose config
valid, CI workflow YAML parses.

Co-Authored-By: WOZCODE <contact@withwoz.com>
The gate passed locally and failed on its first CI run: docker-compose.yml
declares `env_file: - .env`, and `docker compose config` refuses to render a
file whose env_file is missing. `.env` is gitignored, so it exists on a
developer box and never on a runner — the one environment difference that a
gate resolving compose files was always going to trip over, and that grepping
the file would not have.

The gate now creates a throwaway .env when absent and removes ONLY what it
created, so a developer's real .env is never touched. Verified all four ways:
with no .env it passes on a correct tree and still FAILS on a reintroduced
loopback bind; with a real .env present it passes and leaves the file
byte-identical; and it leaves no stray .env behind either way.

Co-Authored-By: WOZCODE <contact@withwoz.com>
…lean

The cleanup trap was `[ "$CREATED_ENV" = "1" ] && rm -f …`. When the repo
already has a .env — i.e. every developer machine — the test fails, and an
EXIT trap whose last command fails sets the script's exit status. So the gate
printed three OK lines and exited 1.

It only showed up under the pre-push hook, because running it interactively
and reading the output looks like success; you have to check $? to see it.
Now the trap ends with an explicit `return 0`.

Verified all four combinations by exit code, not by eyeballing output: passes
with a real .env present, passes with none, still FAILS on a reintroduced
loopback bind, and the full pre-push hook now exits 0.

Co-Authored-By: WOZCODE <contact@withwoz.com>
@cofade
cofade merged commit 3599b77 into main Aug 18, 2026
20 checks passed
@cofade
cofade deleted the claude/codebase-audit-plan-dypztx branch August 18, 2026 00:18
cofade added a commit that referenced this pull request Aug 18, 2026
Senior review of the production-branch adoption. Two P0s, both verified with
single git commands against the repo rather than read off the prose.

1. THE DOCUMENTED CUTOVER WOULD HAVE REVERTED PRODUCTION. The procedure said
   "git checkout production && git reset --hard origin/production" with no
   promotion step. But origin/production is a0e7374, four commits behind main:

     git show origin/production:scripts/deploy.sh | grep '^BRANCH='  -> "main"
     ...and 0 hits for FAILED_MARKER|NPM_CI_RAN|PIP_FAILED|add_reload|
        rollback_health_targets|HUSKY=0   (26 on this branch)

   So running it today rolls the live host back past #193, #196 and #222 --
   discarding the deploy hardening and the security track-A work -- and leaves
   the checkout on `production` running a driver that tracks `main`. That takes
   the old bare `log "skip"; exit 0` path: deploys stop silently, forever, and
   the wrong-branch alert that would have said so was reverted away with
   everything else. Silent, permanent, self-concealing.

   The procedure is now explicitly ordered: promote, VERIFY
   (`git show origin/production:scripts/deploy.sh | grep '^BRANCH='` must print
   production), then check out. The same ordering and the same verify command
   are in the Discord alert body, because that is what the operator actually
   reads at 2am.

2. ADR-030's recorded root cause was FALSE, and it was the sole justification
   for force-resetting a release branch:

     git rev-list --max-parents=0 main    -> d9ac93d   (one root)
     git rev-list --max-parents=0 bf8b314 -> d9ac93d   (the SAME root)
     git merge-base main bf8b314          -> da1b21d
     git rev-list --count main..bf8b314   -> 25        (not 136)

   There was no orphan root, no rebuilt history, no unrelated ancestry -- a
   merge was available the entire time and was rejected for tidiness. The
   cited #124 is a senior-reviewer config commit. Corrected in ADR-030 and in
   chapter 11, kept as a visible correction rather than a quiet edit, because
   ch11 had already generalised the false cause into a "how to avoid this next
   time" rule that future maintainers would have applied to a scenario that
   never happened.

Also from this round:
- "A real promotion gate" was overstated: neither branch has protection, CI
  runs on main only, and any fast-forwarding commit is accepted. Now says
  gate-by-convention, and states that production MUST stay unprotected --
  protecting it would reject publish_firmware's push and ship OTAs whose
  SEQUENCE bump is not in git.
- The ADR's one acknowledged invariant violation (publish_firmware commits to
  production) had "tracked as a follow-up" with nothing behind it. Filed #225.
- The OTA notification -- attached to the single irreversible action in the
  system -- cited ADR-028 (ML inference server-side) instead of ADR-030. This
  is the ADR-renumber-on-collision trap the repo already documented.
- The archive tag is NOT on the remote (`git ls-remote --tags origin` finds
  nothing), so the 25 commits survive only via a stale branch clean_gone would
  delete. Both docs now say so instead of claiming a recovery point exists.
- CONTRIBUTING.md, which CLAUDE.md names as the authority for the branch
  model, said only "branch off main" and never mentioned production. It now
  documents the promote-don't-PR rule and the never-force-push constraint.
- The branch-mismatch marker is hoisted to a BRANCH_MARKER constant next to
  FAILED_MARKER (it was a local var re-typed as a literal in the rm), and the
  marker is now written BEFORE notify -- notify can fail under set -e, which
  would have produced the every-two-minutes alert the marker exists to stop.

Not done, stated plainly: image-service/tests/test_upload.py carries ruff
format reflow. main's version is not ruff-clean, and the pre-commit hook
reformats any staged .py, so it cannot be reverted without bypassing the hook.

Verified: bash -n and shellcheck -S info clean; #196's 11-case rollback
harness and 13-case gate matrix still pass; all five repo gates green.

Co-Authored-By: WOZCODE <contact@withwoz.com>
cofade added a commit that referenced this pull request Aug 18, 2026
* chore: adopt production as the gated release source (#152)

Reconcile the documented services deploy source with reality and unify it
with firmware OTA on a single gated `production` branch.

Investigation for #152 found three stacked problems: the docs named
`production` while the live auto-deploy pulled `main`; firmware OTA and the
services track were documented as separate; and `main`/`production` shared
no common git ancestor (main's history was rebuilt), so `production` could
never fast-forward and silently rotted.

Decision (per maintainer): `production` becomes the single gated release
branch for both web services and firmware OTA. `main` is the integration
line; a release is a fast-forward of `production` onto a chosen `main`
commit. `prod-*` tags are cut on `production`.

- scripts/deploy.sh: BRANCH main -> production; branch-agnostic notify text
- production-deployment.md: drop drift warning; add release/promotion +
  one-time host cutover section
- production-runbook.md: document the promote-then-pull model
- firmware-release.md: rewrite the branch & tag model (both tracks on
  production); replace the "known drift" callout with a history note;
  update the release-checklist commit/tag step
- chapter 11: mark the drift lesson RESOLVED; record the unrelated-history
  root cause and the fast-forwardable-deploy-branch rule
- new ADR-028; update README/esp-flashing/CLAUDE.md pointers

The branch reconciliation (archive tag + force-reset of origin/production)
and the one-time prod-host checkout are operator steps documented in
ADR-028 and production-deployment.md, to run after this lands on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017drgAN84qrn61eZ1yZTdgS

* docs: add "never release from main" critical rule to CLAUDE.md

The production-as-gated-release-branch model (#152 / ADR-030) was only
spelled out in the firmware-OTA section. Add a concise hard rule to the
top-level "Critical rules (do NOT violate)" list so every session knows
prod releases ship from `production`, never from `main`. Links to the
full mechanics rather than duplicating them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: correct ADR-030's root cause and invert the cutover order

Senior review of the production-branch adoption. Two P0s, both verified with
single git commands against the repo rather than read off the prose.

1. THE DOCUMENTED CUTOVER WOULD HAVE REVERTED PRODUCTION. The procedure said
   "git checkout production && git reset --hard origin/production" with no
   promotion step. But origin/production is a0e7374, four commits behind main:

     git show origin/production:scripts/deploy.sh | grep '^BRANCH='  -> "main"
     ...and 0 hits for FAILED_MARKER|NPM_CI_RAN|PIP_FAILED|add_reload|
        rollback_health_targets|HUSKY=0   (26 on this branch)

   So running it today rolls the live host back past #193, #196 and #222 --
   discarding the deploy hardening and the security track-A work -- and leaves
   the checkout on `production` running a driver that tracks `main`. That takes
   the old bare `log "skip"; exit 0` path: deploys stop silently, forever, and
   the wrong-branch alert that would have said so was reverted away with
   everything else. Silent, permanent, self-concealing.

   The procedure is now explicitly ordered: promote, VERIFY
   (`git show origin/production:scripts/deploy.sh | grep '^BRANCH='` must print
   production), then check out. The same ordering and the same verify command
   are in the Discord alert body, because that is what the operator actually
   reads at 2am.

2. ADR-030's recorded root cause was FALSE, and it was the sole justification
   for force-resetting a release branch:

     git rev-list --max-parents=0 main    -> d9ac93d   (one root)
     git rev-list --max-parents=0 bf8b314 -> d9ac93d   (the SAME root)
     git merge-base main bf8b314          -> da1b21d
     git rev-list --count main..bf8b314   -> 25        (not 136)

   There was no orphan root, no rebuilt history, no unrelated ancestry -- a
   merge was available the entire time and was rejected for tidiness. The
   cited #124 is a senior-reviewer config commit. Corrected in ADR-030 and in
   chapter 11, kept as a visible correction rather than a quiet edit, because
   ch11 had already generalised the false cause into a "how to avoid this next
   time" rule that future maintainers would have applied to a scenario that
   never happened.

Also from this round:
- "A real promotion gate" was overstated: neither branch has protection, CI
  runs on main only, and any fast-forwarding commit is accepted. Now says
  gate-by-convention, and states that production MUST stay unprotected --
  protecting it would reject publish_firmware's push and ship OTAs whose
  SEQUENCE bump is not in git.
- The ADR's one acknowledged invariant violation (publish_firmware commits to
  production) had "tracked as a follow-up" with nothing behind it. Filed #225.
- The OTA notification -- attached to the single irreversible action in the
  system -- cited ADR-028 (ML inference server-side) instead of ADR-030. This
  is the ADR-renumber-on-collision trap the repo already documented.
- The archive tag is NOT on the remote (`git ls-remote --tags origin` finds
  nothing), so the 25 commits survive only via a stale branch clean_gone would
  delete. Both docs now say so instead of claiming a recovery point exists.
- CONTRIBUTING.md, which CLAUDE.md names as the authority for the branch
  model, said only "branch off main" and never mentioned production. It now
  documents the promote-don't-PR rule and the never-force-push constraint.
- The branch-mismatch marker is hoisted to a BRANCH_MARKER constant next to
  FAILED_MARKER (it was a local var re-typed as a literal in the rm), and the
  marker is now written BEFORE notify -- notify can fail under set -e, which
  would have produced the every-two-minutes alert the marker exists to stop.

Not done, stated plainly: image-service/tests/test_upload.py carries ruff
format reflow. main's version is not ruff-clean, and the pre-commit hook
reformats any staged .py, so it cannot be reverted without bypassing the hook.

Verified: bash -n and shellcheck -S info clean; #196's 11-case rollback
harness and 13-case gate matrix still pass; all five repo gates green.

Co-Authored-By: WOZCODE <contact@withwoz.com>

* docs: retract the history claim in the runbook too, and price the cutover honestly

Round-2 review. The pattern in the finding is the same one this PR is about.

1. The false "shared no common ancestor" claim was fixed in ADR-030 and
   chapter 11 and LEFT STANDING in docs/07-deployment-view/firmware-release.md
   -- so the branch shipped three documents describing one event, two of which
   called the third a fabrication. The survivor was the runbook: the file
   someone opens WHILE cutting a release, while the retractions sat in an ADR
   and a tech-debt log nobody reads mid-release. Fixed, with the retraction
   visible there too. Swept the tree afterwards; the only remaining matches are
   inside the correction blocks that quote the claim in order to retract it.

2. "verified stale" did not survive its own standard. The ADR had just spent
   twenty lines explaining why unverified assertions here are dangerous, and
   then rested the whole justification for discarding 25 commits on an
   enumeration that omitted four test files and never mentioned the ESP work.
   Re-derived from the repo, and one finding is worth the trouble: the archived
   TIP (bf8b314, "use esp_task_wdt_reconfigure and defer loopTask subscribe
   past AP setup") uses an API that appears NOWHERE on main --
   `git grep esp_task_wdt_reconfigure origin/main -- ESP32-CAM/` is empty, and
   main still uses the IDF-4 esp_task_wdt_init/add pair. Main fixes the same
   AP-mode reboot loop a different way (>=60s TASK_WDT_TIMEOUT_S plus
   runAccessPoint feeding the watchdog, recorded as fixed in
   troubleshooting.md), so nothing live is lost -- but "already exists on main"
   was the wrong description, and the ADR now says what was actually checked.

3. The OTA notification told the operator to run `git checkout main` with no
   statement of WHERE. It arrives while they are looking at the host, and the
   commands run there -- where `git checkout main` immediately trips the
   branch-mismatch guard this same PR adds and pauses every deploy. Now says
   "FROM A MAINTAINER CLONE, NOT THIS HOST" and explains the consequence.

4. The reordered cutover fixed the direction but not the rebuild.
   `git reset --hard` restores the source tree only; backend/dist,
   homepage/dist and node_modules stay at whatever the host last built, and no
   later tick repairs that because deploy.sh exits at
   `[ "$PREV_SHA" = "$REMOTE_SHA" ] && exit 0`. Step 3's own health checks pass
   in that skewed state. Added an explicit rebuild, and said when it can be
   skipped.

5. docs/02-constraints/README.md -- which CLAUDE.md's critical-rules section
   names as the full list -- had no production-branch rule at all. This PR
   found and fixed exactly that gap in CONTRIBUTING.md and left the file
   CLAUDE.md points at. Added, including the never-force-push and
   must-stay-unprotected constraints.

P2s: the ADR said CI "runs on main only" (it triggers on main push+PR and never
on production -- same conclusion, wrong sentence); the Decision section
described the OTA publish as unconditional when the whole block is gated behind
FIRMWARE_AUTO_OTA=1; and chapter 11's "how to avoid" paragraph still led with
the history-rewrite scenario the correction above it calls fictional -- it now
leads with the real rule (no promotion mechanism, no staleness signal).

Verified: bash -n and shellcheck -S info clean, the 11-case rollback harness
passes, all repo gates green.

Co-Authored-By: WOZCODE <contact@withwoz.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: WOZCODE <contact@withwoz.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants