Project-Template/docs/architecture/README.md

84 lines
7.3 KiB
Markdown
Raw Permalink Normal View History

# 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`](../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 |
feat(ops): restore-check.sh, the other half of backup.sh backup.sh says it outright -- it verifies the artefact, only a restore verifies the backup -- and its header names this script as the missing half, with the pg_restore command it should run. This is that command with the parts that stop it being dangerous. A dump pg_restore --list can read is a file with a table of contents, not a database. Between those sit every reason a restore fails on the day it is needed: a missing extension, an owner that does not exist, version skew, a dump of the wrong database that reads perfectly. And the number nobody has and will want badly: how long it takes. During an incident that decides whether you restore or fail over, and it is unknowable from the file size. Printed every run. **The dangerous part.** pg_restore --clean issues DROPs, and pointed at production it obeys immediately and irreversibly. Handled by never accepting a target: there is no --database flag, because naming the database is the mistake. The script creates `restorecheck_<epoch>_<pid>`, restores into that, and drops it from a trap so an interrupted run leaves no copy of production data behind. Same argument status.sh makes for having no --host flag. Shares BACKUP_DIR, BACKUP_NAME and BACKUP_MIN_TABLES with backup.sh rather than taking its own, so the two cannot disagree about which series belongs to this project. Proved against a real PostgreSQL, not asserted -- GUARDS.md section 1: exit 0 a real 3-table dump, minimum 1 exit 1 minimum raised to 99; a dump truncated to 2000 bytes; a zero-byte dump exit 2 unconfigured (naming the missing value one at a time); server unreachable exit 0 --dry-run, always, contacting nothing Two things that testing found and assertion would not. Every scratch database was dropped, confirmed by querying pg_database afterwards. And --dry-run could exit 1 on an empty dump, because the emptiness check ran before it; a mode whose exit code depends on the state of the data is not a dry run, so the check moved below and the dry run now notes the emptiness in its plan instead. closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:07:36 -05:00
| `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 |
feat(security): preflight.sh, the live-URL checks Four things auditors of applications of this kind report seeing over and over, each of them mechanical: a header that is absent, a scheme that is plain, a login that answers a thousand guesses, a reset form that confirms which addresses have accounts. None needs understanding to be checked, which is why they belong in a script rather than a page somebody re-reads before a release and then does not. *(precautionary)* -- none of it has bitten a project here. The checks are cheap and the evidence is somebody else's. **Passive by default.** A bare run sends two GETs and could not be mistaken for anything. Rate limiting and enumeration are behind --auth, because one of them deliberately generates a dozen failed authentications. **It refuses any host but its configured origin.** There is no URL argument that can point it elsewhere: the target is PREFLIGHT_ORIGIN, and a URL on the command line must match it. status.sh makes this argument for having no --host flag; here there is more at stake, since a mistake there reads the wrong machine and a mistake here hammers somebody else's login form from your address. The login and reset paths are configured too, never guessed -- a POST to an assumed /login on the wrong app posts to whatever is actually there. Verified: exit 2 unconfigured, for a foreign host, and for an unreachable one; exit 0 for --dry-run; exit 1 with the finding named. Run passively against privacyllc.dev it correctly reported a strong CSP, a framing policy and HSTS, and found that plain http answers 200 with the full page rather than redirecting -- which is the class of finding this exists for, on its first real target. closes #9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:14:06 -05:00
| `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 |
feat(ops): controls.sh, which controls this project actually has The question that decides whether a system can be depended on is not "does it work" -- a demo answers that -- but which controls are present, asked before something goes wrong. verify.sh reports which checks ran just now; backup.sh proves one dump; the Command Center reports documents and tracker labels. None of them says whether this project has backups AND has ever restored one AND has somewhere errors go AND has an environment that is not production. Four states, because flattening them is how a report starts lying: measured observed here -- a file with a date, a variable that is set, a command that answered declared asserted in configuration, checked for shape and not for truth n/a the project said the control does not apply. A library has no uptime; saying so is an answer, not an omission unknown expected and undeterminable. Never rendered as absent, because "I could not tell" and "it is not there" send people to different places Reads BACKUP_DIR, BACKUP_NAME, HEALTHCHECK_BASE_URL, STATUS_HOST and STATUS_CONTAINER from the scripts that own them, so the two cannot disagree about which project this is. Writes nothing: a committed CONTROLS.md saying "backups: ok" is a description of current state in a document, which is what the batch ledger was and why it was archived. Verified: exit 2 when nothing is declared and when a control name is unknown; exit 0 for a library that declares only what applies, with seven n/a rows; exit 1 with three absent and one unknown; --quiet showing only rows needing attention. One bug that testing found and reading would not. GNU date parses relative English, so `CONTROLS_LAST_RESTORE="last tuesday"` returned a real timestamp and a plausible age -- a restore date the script invented. The shape is now required before date sees it, and prose becomes unknown rather than a number. closes #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:11:52 -05:00
| `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 |
fix(tools): doc-triggers could not see the documents at the repository root Collection started at DOCS.rglob("*.md"), so nothing at the root was read. README.md, project-readme-template.md and both START-HERE documents carry a full status header, and Governs lines nothing ever looked at. Changing a file they govern fired nothing, and the run said "No document's Governs matched these paths" -- true of the tool, false of the repository. Third instance of one shape in two days, each a level further out. Documents whose Governs carried a gloss were handed a glob no file could satisfy; before that a root resolved by depth pointed the whole tool outside the repository; here four documents were never collected at all. Every one of them printed something reassuring while checking less than it claimed. Root documents are now collected alongside the tree. The root walk is glob, not rglob -- deliberately one level deep, so a vendored copy of this template, a scratch checkout or somebody's directory of notes cannot enrol its documents as governing the project that holds it. Verified: a vendor/Template/README.md declaring Governs: src/** is not consulted. ## Status is what separates a document from a template for one project-readme-template.md carries `Status: <Current | Draft | Superseded | Archived>` and a Governs describing the README of whichever project copies it. Collecting the root without a guard would trade a document that never fires for a template that always does, which is the pair of failures this script has spent two days on. DOC_TRUST_MAP.md already makes the status vocabulary a rule with a checker behind it, so that is the test: a document whose Status is not one of the four words is not treated as governing anything here. It is **named, not dropped** -- when such a document governs a path in the change it is listed with its status, because a silent exclusion is the failure being fixed, not a smaller version of it. A document that governs a subject rather than paths can never fire mechanically, so an unfilled one is left out of the judge-these-yourself list entirely rather than sitting in it permanently. Both branches were exercised: a root template governing src/** is named and not fired; the same file with Status: Current fires normally. The no-match message now covers this case too, rather than claiming nothing matched when something did and was set aside for a stated reason. ## Documents architecture/README.md's row says where doc-triggers reads from, which has changed. DOC_TRUST_MAP.md owns the status header: it now says root documents carry one and are read, that the root is one level deep and why, and what the four status words separate. Verified from a clean clone: touching START-HERE-New-Project.md fires README.md; docs/ behaviour is unchanged across a modified script, a modified document, a modified githook, a branding asset and a staged deletion; the scripts/ copy still resolves its own root. doc-claims reads 116 claimed paths across 23 files, all present. closes #22 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 00:12:49 -05:00
| `scripts/doc-triggers.py` | which documents a **pending** change fires, read from the `Governs:` headers of everything under `docs/` and of the documents at the repository root, narrowed by the optional `Fires on:` — the kinds of change (added, deleted, moved, changed) a document's trigger actually names, so one governing `docs/**` for existence changes alone does not fire on every edit. 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 |
feat(guards): gate the claim of being finished, not only the artifact Every guard in this template fires on an artifact -- `githooks/pre-commit` on a commit, `audit-gate.mjs` and `preflight.sh` on a release, `verify.sh` when somebody runs it. `GUARDS.md` §6 is the rule they follow: guards belong before the artifact exists. An agent that says "done" and does not commit produces no artifact and trips none of them. That gap already has two rules pointing at it -- `WORK_CYCLE.md` opens with *"Done" is not a close*, and the tracker convention refuses a close under fifteen characters of evidence -- and a rule is a thing a reader can skip. This is the same rule with an exit code. `scripts/verify-before-done.sh` is a Claude Code `TaskCompleted` hook. Verified against Claude Code 2.1.251: the event exists and the binary carries the string `TaskCompleted hook prevented continuation`. WIRED TO `verify.sh`, NOT TO THE TEST COMMAND. `npm test` in a repo with no tests exits 0, so a gate built on it reports green having verified nothing -- `GUARDS.md` §4 and §8's whole subject. `verify.sh` already distinguishes the three answers, and this treats exit 2 (nothing was verified) as a failure with its own message rather than a pass. A repository with no checks yet fails this gate loudly, which is the intended answer. IT EXITS 2 TO BLOCK, AND THAT IS NOT THE USUAL CONVENTION. Claude Code reads a `1` from a hook as "the hook broke" and continues, so a gate written the ordinary way fails OPEN and lets through exactly what it was installed to catch, looking identical from the outside. `docs/TOOLS.md` now says this beside the exit-2-is-never-a-pass rule it inverts. THE LIMITATION IS IN THE HEADER IN CAPITALS, because it decides whether this is worth installing: it gates Claude Code and nothing else. A Codex session or a human in the same checkout writes past it. It is a second layer and never the layer -- `.githooks/pre-commit` is what git runs whoever is driving, and if a project's real suite is not wired into that hook, wiring it there is worth more than installing this. Proved rather than assumed, all four outcomes, per §1: no `verify.sh` -> 2 (fails closed); pass -> 0 with empty stderr; fail -> 2 naming the code; verified-nothing -> 2 with the distinct message. Docs in the same commit, per the triggers `doc-triggers.py` reported: `architecture/README.md` gains the row its own "Adding one" rule requires, `TOOLS.md` gains the paragraph under "Which ones can stop you", and `DOC_TRUST_MAP.md` gains the question it now answers. `doc-claims.sh` re-run: 51 claimed paths across the three, all present. Prompted by an XDA piece on Claude Code shipping unverified work. The idea is theirs; the exit-2 contract, the `verify.sh` wiring and the Codex caveat are what it needed to be true here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:42:00 -05:00
| `scripts/verify-before-done.sh` | a Claude Code `TaskCompleted` hook: runs `verify.sh` and **blocks the claim of being finished** when it fails. One step earlier than `GUARDS.md` §6 — every other guard here fires on an artifact, and an agent that says "done" without committing produces none. Wired to `verify.sh` rather than the test command because `npm test` on a repo with no tests exits 0, and exit **2** (verified nothing) is treated as a failure, not a pass. Exits 2 to block: a hook exiting 1 fails **open**. Gates Claude Code only — a Codex session in the same checkout writes past it, so `.githooks/pre-commit` remains the gate that catches every writer |
| `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.