Project-Template/docs/architecture
null eaf42c38ea fix(guards): prove-guard rejected correct guards, and refused with the wrong code
Two defects in the same script, both found by running it against a node --test
suite.

## The count read one order, and the fallback is not conservative

The failure count preferred the runner's own summary through a single pattern,
`[0-9]+ (tests? )?failed`. That matches vitest, pytest and Gradle and nothing
else. Runners that put the number on the right matched nothing: `fail 1` from
node --test, `Failures: 2` from Maven and JUnit, `failures=2` from python
unittest, `# fail 1` from TAP. All of them fell through to counting lines that
match $PROVE_GUARD_FAIL_PATTERN.

That fallback overcounts, and `[ "$COUNT" -gt 1 ]` exits 3. A guard over a status
enum, mutating the string 'FAILED', matches FAIL_PATTERN three times inside one
AssertionError diff -- the message, the diff line, and the actual array. So a
single failing test, from a guard behaving perfectly, exited 3 with "but 3
failures" and the advice to "narrow the guard, or narrow the mutation". Followed,
that advice weakens a correct guard.

The script's own header records this exact false fire being tried and rejected:
"a naive count calls that six coincidental failures. Tried that first; it fired
on the very first run against a guard that was behaving perfectly." It was
rejected as the primary strategy and left reachable as the fallback. The message
compounded it, reporting "this runner printed no summary" about a runner that
printed one this script could not read.

GUARDS.md already claims the count "comes from the runner's own summary rather
than from eyeballing red". For four common runners that was false. The code now
matches the claim, so no document needed changing -- the document was right.

A second pattern reads the number on the right, last match wins, before the
approximate fallback. The `[:= ]` class is what reaches python unittest's
`failures=2`. Two genuinely failing tests still report 2 and still exit 3.

## Refusing is not a diagnosis, and it was using the diagnosis code

The mutation step refuses when the find-string is absent or ambiguous, and both
used `sys.exit("message")`. That prints to stderr and exits 1 -- the code this
script reserves for "the guard stayed GREEN with its target broken".

So a typo in the find-string returned a verdict about the code under test, from
a run that never mutated anything and never executed the guard. The two states
it most matters to distinguish were indistinguishable, and the wrong one is the
alarming one. TOOLS.md teaches callers to read these codes and that "two is
never a pass"; every other refusal path here already exited 2, only the embedded
Python did not. Both refusals now raise SystemExit(2) through a helper that
still writes the message to stderr.

Both codes are non-zero, so no CI run passed that should have failed. This was a
wrong diagnosis, not a missed failure.

## Verified

The full exit matrix against node --test: correct guard 0, guard that cannot
fail 1, two genuine failures 3, bad arguments 2, absent find-string 2, ambiguous
find-string 2, missing file 2. The restore trap fires on every one and the file
comes back intact. vitest, pytest and Gradle summaries still resolve through the
first pattern, unchanged.

closes #18
closes #19

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:39:17 -05:00
..
githooks chore(repo): put the template under version control 2026-08-17 22:44:26 -05:00
scripts fix(guards): prove-guard rejected correct guards, and refused with the wrong code 2026-08-17 23:39:17 -05:00
GUARDS.md chore(repo): put the template under version control 2026-08-17 22:44:26 -05:00
README.md feat(security): preflight.sh, the live-URL checks 2026-08-17 23:14:06 -05:00

README.md

Architecture

Status: Current
Owner: <who maintains this>
Last reviewed: <YYYY-MM-DD>
Governs: docs/architecture/**
Review trigger: Any new module, any change to a module boundary or a data shape

What belongs here

How the thing is built, for somebody who has to change it:

  • Module boundaries — what each part owns, and what it is not allowed to know about. The boundaries are the architecture; everything else is detail.
  • Data shapes — the structures that outlive a single function, especially anything persisted or sent over a wire.
  • Reference manuals — the long documents that answer "how does X work" without requiring a full read of X.
  • Decisions with consequences — why this database, why this concurrency model, why this dependency. Include the option that was rejected and what it would have cost, because that is the part nobody can reconstruct later.

Documents here

  • GUARDS.md — how to write a check that actually checks. Read it before adding a structural test or a probe; every rule in it was learned from a guard that had been green over something broken.

What ships in this folder

Working code, not just prose. Copy what a project needs and delete the rest — these are a starting point with the arguments already made, not a framework.

This table is the one copy of that list. docs/TOOLS.md is the signpost every project is expected to have — it points here rather than repeating it, and answers the two questions this table does not: which scripts can stop you, and where to start in a fresh clone.

Path What it is
scripts/release.sh version bump, guards, build, verify, push, prune. Refuses to build on a half-run test suite or a malformed public origin.
scripts/verify.sh the repo's own checks, in one command
scripts/check-env.sh which variables are set, which are missing, before anything reads them
scripts/migrate.sh apply and report migrations, including the ones that run outside a transaction
scripts/backup.sh a dump that is verified before it is trusted
scripts/restore-check.sh the other half of backup.sh: restores the newest dump into a scratch database it creates and drops, counts the tables, and times it — the number an incident actually needs. Never accepts a target, because naming one is the mistake --clean punishes.
scripts/healthcheck.sh a liveness tick with the URL written down rather than re-derived each run
scripts/preflight.sh the live-URL checks: headers, TLS, and (with --auth) login rate limiting and account enumeration. Refuses any host but its configured origin — two of its checks generate failed logins and look like an attack in somebody's log.
scripts/status.sh what is deployed, and whether it matches this checkout
scripts/controls.sh which operational controls this project actually has, each row saying how it is known: measured, declared, n/a, or unknown. An unknown is never rendered as absent — "I could not tell" and "it is not there" send people to different places.
scripts/dev.sh bring the local stack up
scripts/scaffold.sh lay out a new project in this shape
scripts/doc-triggers.py which documents a pending change fires, read from their Governs: headers. The Review trigger on each document names the change that should send somebody back to it; this is the check that asks before the commit rather than after
scripts/prove-guard.sh breaks the thing a guard protects, requires the guard to go red, restores the file from a trap. GUARDS.md §1 written out as a command, including the count — one failing test reported on six lines is not six failures
scripts/commit-mine.sh commits only the paths you name, by pathspec, after the secret scan. For a tree something else is also writing: what anyone else has staged is reported and left exactly as it was
scripts/doc-claims.sh every file a document names must exist, and (--covers) every file that exists is named — the second is the one that catches a list missing rows
scripts/duplication.py code that exists twice, tuned so what it reports is worth reading
scripts/dead-code.py exports nothing imports, and assets nothing renders
scripts/secrets.sh credential shapes in a staged diff, using the project's own patterns where it has them
scripts/audit-gate.mjs high/critical advisories in production dependencies, with the allowlist npm does not have. An entry must say why the advisory cannot reach this app, what would make it reachable, and what retires the entry — three fields, so a waiver stays falsifiable. Exits 2 when nothing was checked.
scripts/forgejo-issue.py file and close issues in the tracker convention, with every rule of it as a check
scripts/deploy.py update the running stack to a published image. Publishing and deploying are separate; this is the second one. The only copy — it existed twice and drifted (#209); the privacyllc-deploy skill's is now a symlink to this file. Identity-free by design: it reads DEPLOY_IMAGE, DEPLOY_STACK_ID, DEPLOY_CONTAINER and DEPLOY_SITE_URL from the environment and refuses to run without them, so each project supplies its own via a wrapper. Never hard-code one here — least of all the site URL, which is frozen into the image at build time.
scripts/release-notes.mjs tags the release and writes its notes, grouped by the commit types the message hook already enforces. Runs after release.sh has published, so a failure here cannot cost an image. Scrubs credential shapes out of commit subjects first — the body goes to a public repository.
githooks/ pre-commit, commit-msg, post-commit — see its README for the one install command

Every script takes its configuration from the environment and hard-codes nothing about any particular deployment. check-env.sh is the one to run first.

What does not belong here

  • Product intent — that is docs/planning/PROJECT_PLAN.md
  • What it should feel like — that is docs/design/
  • What happened while building it — that is a history log, not architecture

A note on drift

Architecture docs go stale faster than any other kind, because code changes under them silently. This is exactly what the Review trigger line is for: name the change that should send somebody back here, and a reader can tell whether the trigger has fired.