Vanilla-first baseline. Every rule ships a site that passes Mozilla HTTP Observatory at A+ (score ≥ 100), meets OWASP ASVS 4.0.3 Level 2, and meets WCAG 2.2 AA. New builds adopt it as their starting baseline; existing projects are measured against it, and any gap is closed or recorded as a deliberate, documented override by entry ID.
Precedence. A project's own CLAUDE.md overrides any entry here (e.g. Overrides STK-004: MariaDB 10.6). IDs are permanent; retire a rule as [DEPRECATED], never delete or renumber. Tags: Blocker stops the release · Ban absolute prohibition · Review a known trade-off.
Compliance targets
- CMP-001BlockerMozilla HTTP Observatory: grade A+, score ≥ 100, no
-modifier on any category. - CMP-002BlockerQualys SSL Labs: grade A+ on TLS.
- CMP-003BlockerOWASP ASVS 4.0.3 Level 2 minimum across authentication, session management, access control, validation, cryptography, error handling, data protection, HTTP and configuration.
- CMP-004BlockerWCAG 2.2 AA on every user-facing surface.
- CMP-005HTTPS only. HTTP requests 301 to HTTPS on the same host. No mixed content.
- CMP-006Every cookie carries
Secure,HttpOnly(unless JS access is genuinely required) andSameSite=Laxminimum —Stricton auth cookies where UX allows. - CMP-007BlockerNo change ships that regresses CMP-001 through CMP-006.
Stack baseline
Mandatory.- STK-001Host: Synology NAS, DSM 7 / Web Station.
- STK-002Web server: Apache.
- STK-003PHP 8.2-FPM.
- STK-004MariaDB 10.11. InnoDB engine, foreign keys enforced,
utf8mb4/utf8mb4_unicode_ci. - STK-005BanFrontend is vanilla HTML5 + CSS3 + JavaScript. No frameworks unless explicitly requested per project.
- STK-006BanNo Composer, no npm, no build step, no CLI-only tooling, no SSH. Deployment is File Station zip upload plus phpMyAdmin.
- STK-007BanNo new library, dependency or architectural pattern without explicit approval.
Scaffold & filesystem layout
/ ← repo root (NOT web-served) ├── public/ ← DocumentRoot: entrypoints + static only │ ├── index.php .htaccess assets/ css/ favicon.ico ├── config/ (config.php schema.php constants.php) ├── src/ ← PSR-4 application code ├── storage/ ← logs, cache, uploads (writable; NOT web-served) ├── db/ (migrations/) ├── .env ← secrets; 0600; never committed ├── .env.example ← every key documented, no values └── CLAUDE.md
- SCF-002Apache
DocumentRootpoints at/public. Nothing outside/publicis web-reachable. - SCF-004
.envlives outside/public, mode0600, owned by the web server user, never committed. - SCF-008Global layout partials — header, footer, drawer, error and legal shells — live in
/src/views/,required in. Never web-served;HTA-004denies/srcat the Apache layer too. - SCF-009CSS lives under
/public/css/split by responsibility. Load order governed by an explicit manifest in the header partial.
Configuration & environment
- CFG-001
config/config.phpparses.env, validates againstconfig/schema.php, returns an immutable array. Nothing else reads.envdirectly. - CFG-003BlockerBoot fails loudly — HTTP 500 plus
error_log()— if any required key is missing, empty or fails type validation. - CFG-004BanNo silent defaults for secrets. A missing secret is a boot failure, never a fallback value.
- CFG-007
APP_ENV=production→APP_DEBUG=false, error display off, generic error pages, verbose logging server-side only. - CFG-009
APP_ENV=development→ error display permitted, but only when served to127.0.0.1or a private LAN address. Never on a public interface.
Boot sequence
public/index.php executes in exactly this order. Prescriptive.- BOOT-001
declare(strict_types=1); - BOOT-002Load config; parse and validate
.env; abort on any failure. - BOOT-003
date_default_timezone_set('UTC'). Runtime is always UTC. - BOOT-005Register the PSR-4 autoloader (filesystem map, no Composer).
- BOOT-008Emit security headers, generate the per-request CSP nonce, then start the session. Never before.
- BOOT-009Dispatch through the front controller.
Deployment & release
- DEP-001A deploy zip contains only files created or updated in that session, mirroring live paths relative to the web root.
- DEP-003Every changed file carries a dated comment header naming the bundle or change request.
- DEP-006BanNever
cat >>onto a PHP class file. Usestr_replaceagainst a unique pre-closing-brace anchor. - DEP-008Additive schema migrations run before the code deploy; removals run after. Never both directions in one deploy.
- DEP-009BlockerThe first post-deploy check is “is
.htaccessin force”. Request a denied path (/.env): 403 means rules apply; 200 means Apache is ignoring.htaccessentirely.
Language conventions
- PHP-001
declare(strict_types=1);is the first statement in every PHP file. - PHP-003Layering: routing → controllers → services → data access. Never mix heavy HTML into business logic.
- PHP-005BanClient-side validation is a UX affordance only. Every rule is re-enforced server-side.
- PHP-006Passwords:
password_hash()/password_verify()with Argon2id,memory_cost ≥ 65536,time_cost ≥ 4. - PHP-007BanEvery
catch (\Throwable $e)musterror_log()with a correlation ID. No silent catch, ever. - PHP-008BanEscape on output:
htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8')on every echo of dynamic data. - PHP-011BanBanned:
eval,create_function, stringassert(),extract()on untrusted data.
Database access
- DB-001PDO only.
- DB-002
ERRMODE_EXCEPTION,EMULATE_PREPARES = false,DEFAULT_FETCH_MODE = FETCH_ASSOC. - DB-003BanPrepared statements with named placeholders. No raw SQL interpolation, ever.
- DB-006Every foreign key declares its
ON DELETEintent explicitly. - DB-007Migrations are plain DDL,
IF NOT EXISTS, idempotent.
Data model conventions
- MOD-001Public identifiers are UUIDv7 — time-ordered, InnoDB-friendly. Stored
BINARY(16). - MOD-003
created_at/updated_atareDATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), stored UTC. - MOD-006BanNo column named
data,misc,extraormetadata. Every field earns its name and type. - MOD-007Money columns:
BIGINTin minor units plus a siblingCHAR(3)currency code.
Cryptography & secrets
- CRY-001Banlibsodium AEAD only. Do not roll custom crypto.
- CRY-002Every ciphertext carries a wire-format prefix and version tag:
{APP}{VERSION}:then base64. Never bare ciphertext. - CRY-004BanSecret env keys never in client code, repo, URL parameters or logs.
- CRY-006BanRandomness is
random_bytes()/random_int(). Nevermt_rand,rand,uniqid.
HTTP response headers
- HDR-001BlockerThe full set is sent on every response, from Apache and/or PHP. Any missing or weakened header blocks the release.
- HDR-002
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload - HDR-004
X-Content-Type-Options: nosniff - HDR-006
Referrer-Policy: strict-origin-when-cross-origin - HDR-008
Permissions-Policylocked down to the minimum feature set the app uses, denying the rest (e.g.geolocation=(), camera=(), microphone=(), payment=()). - HDR-012
ServerandX-Powered-Byremoved. No header discloses server, PHP or dependency versions. - HDR-013
Cache-Control: no-storeon any authenticated or personalised response.
Apache & .htaccess
- HTA-004Explicit deny on
/config,/storage,/dband/src. - HTA-007Blocker
AllowOverride Allmust be set for the document root, or every.htaccessis silently ignored. - HTA-008
<FilesMatch "^\.(?!well-known)">— the negative lookahead keeps.well-known/(security.txt, ACME) reachable. - HTA-011The “map into
/public” loop guard lives in the RULE pattern (^(?!public/)(.*)$), never aRewriteCondon%{REQUEST_URI}. - HTA-012
DirectorySlash Off; reconstruct the redirect fromTHE_REQUESTas an absolutehttps://URL to avoid a/public/leak + HTTP downgrade.
Content Security Policy
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic' 'unsafe-inline' https:;
style-src 'self' 'nonce-{RANDOM}';
img-src 'self' data:; font-src 'self'; connect-src 'self';
form-action 'self'; frame-ancestors 'none'; base-uri 'none';
object-src 'none'; upgrade-insecure-requests;
require-trusted-types-for 'script'; trusted-types default;
report-uri /csp-report;- CSP-002Nonce generated fresh per response, injected into every internal
<script>/<style>. Never reused. - CSP-004BanNo
unsafe-eval, under any circumstances. - CSP-005ReviewThe
'unsafe-inline'token inscript-srcis a deliberate fallback for legacy engines only. A browser that understands'strict-dynamic'ignores it, so trust flows solely from the per-request nonce. Trust never comes from'unsafe-inline'. - CSP-006Any permitted external script carries SRI (
integrity=).
CSRF, navigation & one-time tokens
- TOK-001BlockerEvery state-changing request requires a server-verified CSRF token. GET is never state-changing.
- TOK-004BanVerified with
hash_equals(). Never==, never===. - TOK-010Navigation tokens: TTL default 900s. Expired tokens return 410 Gone, never 404 or a login redirect.
- TOK-013BanNever contains PII, filenames, email addresses or account numbers in cleartext.
- TOK-015BlockerNever the primary authorisation — server-side authorisation still runs on every request. The token gates discoverability, not permission.
- TOK-019One-time links stored server-side as a SHA-256 hash. Never plaintext.
Sessions & cookies
- SES-001Session cookie uses the
__Host-prefix: host-only,Secure,Path=/, noDomain. - SES-003Session storage is server-side, on the filesystem, outside
DocumentRoot. - SES-005
session_regenerate_id(true)on login, logout, any privilege change, and every 15 minutes of activity. - SES-007BlockerNever trust
$_SESSIONto carry an auth decision forward. Re-validate the subject against the database on every privileged action.
Routing & URLs
- RTE-002First match wins. Literal routes registered before parameterised routes.
- RTE-004BanURLs never contain sequential integer IDs (use UUIDv7), email addresses or PII.
- RTE-007Status codes used precisely: 404 not found, 410 expired/consumed, 401 unauthenticated, 403 unauthorised.
- RTE-008BanAll emitted URLs are relative to the current document; the per-request
$UPprefix is the correct pattern.
Date & time
- DT-001Storage is UTC. Always.
DATETIME(6), ISO 8601 / RFC 3339 on the wire. - DT-003BanParse DB values with
new DateTimeImmutable($v, new DateTimeZone('UTC')). Neverstrtotime(). - DT-004A user's zone is an IANA name (
Europe/London), never an offset — offsets do not survive DST.
Currency & money
- MON-002BanStorage is integer minor units in
BIGINT. £12.34 →1234. NeverFLOAT/DOUBLE. - MON-005An amount is a
{minor_units, currency}pair. Cross-currency addition or comparison is a bug. - MON-007Display via
NumberFormatter::CURRENCYwith the resolved locale. Never hand-format£or separators.
Internationalisation & locale
- I18N-001Locales are BCP-47 tags (
en-GB), stored per user, defaulting fromAPP_LOCALE. - I18N-002UTF-8 end to end;
<meta charset="utf-8">the first child of<head>. - I18N-003BanTranslation uses ICU message format via
MessageFormatter. Never build a sentence by concatenation.
Frontend, markup & CSS
- UI-001Semantic HTML5. Reach for a semantic element before a custom component.
- UI-002BanNo inline JavaScript.
- UI-003BanNo inline
style=""on layout elements; no embedded<style>in PHP output. Nonce-tagged critical CSS in<head>only. - UI-007BlockerEvery interactive surface handles loading, empty, error and disabled states explicitly.
- UI-011BanCSS split by concern under
/public/css/. No monolithicapp.css; a manifest governs the cascade. - UI-013BlockerEvery CSS/JS URL carries a filesystem-mtime cache-buster (
?v={filemtime}) paired with a one-year immutableCache-Control.
Typography
- TYP-001The font family is a per-project decision. Little Rascal's choice is Nunito + Maven Pro, self-hosted.
- TYP-002BanWebfonts self-hosted from
/public/fonts/. No Google Fonts, no CDN.font-src 'self'enforces it. - TYP-008Format is subsetted
woff2only. - TYP-015
font-variant-numeric: tabular-numson data tables, metadata and aligned figures. - TYP-016Prose measure targets 68ch, hard-capped at 78ch.
Accessibility
WCAG 2.2 AA.- A11Y-001Every input has a real label. A placeholder is never a label.
- A11Y-002Focus states are visible, not colour alone, contrast ≥ 3:1.
- A11Y-004Touch targets ≥ 24×24 CSS px.
- A11Y-005Full keyboard operability. A skip-to-content link is the first focusable element.
- A11Y-008Respect
prefers-color-schemewhere an alternative theme exists — here it does (dark default + light). - A11Y-011BlockerNo change ships that regresses an existing accessibility behaviour.
Authorisation & MFA
- AUZ-001BlockerAccess control is enforced server-side on every request. Roles are
adminand member, resolved from the database, never inferred from the session alone (pairs withSES-007). - AUZ-002App access is grant based. Administrators reach every app; everyone else needs an explicit grant, checked in one place on both the page and the XHR entry points.
- AUZ-003TOTP MFA is offered per account through the standard
otpauth://URI. Enrolment issues ten one-time backup codes, Argon2id hashed, shown once at creation. - AUZ-004
isAuthenticated()is true only after password plus, where enrolled, a passed MFA challenge. The pending-MFA state never satisfies an authenticated gate. - AUZ-005A destructive admin action gates on a recent step-up re-auth window, defaulting to 900s. The session ID rotates on every privilege change (
SES-005).
File uploads
- UPL-001BanUploaded files are written under
storage/, outsideDocumentRoot. Never inside/public. - UPL-002BanType is decided by content sniff plus an explicit MIME and extension allowlist. The client-supplied filename and
Content-Typeare never trusted. - UPL-003A per-file and per-request size cap is enforced server-side. An oversize upload is rejected with 413, not truncated.
- UPL-004The stored name is generated (UUIDv7). The original filename is metadata only, escaped on display, never used as a path.
- UPL-005BlockerFiles are served back only through a controller that sets
Content-TypeandContent-Disposition. An upload directory never executes PHP.
Email & SMTP
- MAIL-001Delivery uses a single vanilla SMTP client over STARTTLS or implicit TLS. No third-party mail library (
STK-006). - MAIL-002BanSMTP credentials live only in
.envand are an app password, never a real account password (CRY-004). - MAIL-003Every message is multipart with a plain-text alternative. Headers are RFC 2047 encoded and the body is dot-stuffed.
- MAIL-004A send failure is logged with a correlation ID and surfaced to the user as a generic retry message, never the raw SMTP error (
LOG-003). - MAIL-005Mail is best effort for a completed action. A delivery failure never rolls back the action the message confirms.
Known gotchas
An accretive log — add on each trap, never delete.- GOT-005Fixed viewport height on mobile: use
100dvh, not100vh. - GOT-008
ServerTokensis server-config only; in.htaccessit 500s every request.expose_phplives inphp.ini. - GOT-010Windows extractors skip dotfiles. After unzipping a deploy, verify
.htaccess,.env.example,.well-known/actually landed. - GOT-014
FILTER_VALIDATE_URLvalidates syntax, not safety — pair it with an explicit scheme allowlist (https,mailto,tel).
Logging & observability
- LOG-001Every request gets a correlation ID:
X-Request-IDechoed if supplied, UUIDv7 generated if absent. - LOG-002Format is JSON Lines:
{ts, level, request_id, subject_id, event, ctx}. - LOG-003BanNever logged: passwords, tokens, session IDs, cookie values, ciphertext, PII, PAN. Redaction happens at the logger.
Working protocol
- AI-002Make the smallest change that ships the fix.
- AI-004Ask one clarifying question at a time. Never batch questions.
- AI-005Sequence: confirm scope → deliver the file → note the path.
- AI-007Never claim code is tested unless it has actually been executed. Say which environment it ran in.
- AI-008BanRead the file before editing it. Never overwrite content not read in the current session.
- AI-014When the environment is the cause, name it on first delivery — never ship a silent workaround.
Derived ban index
A lookup for the prohibitions above. If a ban and its source disagree, the source wins.| Prohibition | Entry |
|---|---|
| Frameworks (React, Vue, Laravel, Symfony…) | STK-005 |
| Composer, npm, build steps, CLI-only tooling | STK-006 |
| Silent defaults for secrets | CFG-004 |
| Trusting client-side validation alone | PHP-005 |
| Rendering unsanitised HTML | PHP-008 |
| Raw SQL interpolation | DB-003 |
| Custom-rolled crypto | CRY-001 |
| mt_rand / rand / uniqid | CRY-006 |
| unsafe-eval in CSP | CSP-004 |
| == for token comparison | TOK-004 |
| Sequential integer IDs in URLs | RTE-004 |
| FLOAT / DOUBLE for money | MON-002 |
| Inline JavaScript | UI-002 |
| Inline style / embedded <style> | UI-003 |
| Monolithic app.css | UI-011 |
| Third-party font hosting | TYP-002 |
| Logging secrets or PII | LOG-003 |
| Editing a file without reading it first | AI-008 |
| Trusting a client filename or Content-Type on upload | UPL-002 |
| Writing uploads inside /public | UPL-001 |
| A real account password for SMTP | MAIL-002 |
v1.0 · 29 July 2026. Initial vanilla baseline — ID-addressable entries across 25 subject groups plus this derived ban index. Font family selection is left per-project; delivery, licensing, caching and setting rules apply to any chosen face.