Resources · Build & design

Design constraints.

The web application baseline — every design, security, compliance and performance rule written as a concrete, checkable requirement rather than general advice. Every entry carries a stable GRP-NNN ID; a project's own CLAUDE.md overrides an entry only by naming it.

Updated 24 September 2026·v1.1·Source · CLAUDE.md

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.

CMP

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) and SameSite=Lax minimum — Strict on auth cookies where UX allows.
  • CMP-007BlockerNo change ships that regresses CMP-001 through CMP-006.
STK

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.
SCF

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 DocumentRoot points at /public. Nothing outside /public is web-reachable.
  • SCF-004.env lives outside /public, mode 0600, 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-004 denies /src at the Apache layer too.
  • SCF-009CSS lives under /public/css/ split by responsibility. Load order governed by an explicit manifest in the header partial.
CFG

Configuration & environment

  • CFG-001config/config.php parses .env, validates against config/schema.php, returns an immutable array. Nothing else reads .env directly.
  • 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-007APP_ENV=production → APP_DEBUG=false, error display off, generic error pages, verbose logging server-side only.
  • CFG-009APP_ENV=development → error display permitted, but only when served to 127.0.0.1 or a private LAN address. Never on a public interface.
BOOT

Boot sequence

public/index.php executes in exactly this order. Prescriptive.
  • BOOT-001declare(strict_types=1);
  • BOOT-002Load config; parse and validate .env; abort on any failure.
  • BOOT-003date_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.
DEP

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. Use str_replace against 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 .htaccess in force”. Request a denied path (/.env): 403 means rules apply; 200 means Apache is ignoring .htaccess entirely.
PHP

Language conventions

  • PHP-001declare(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) must error_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, string assert(), extract() on untrusted data.
DB

Database access

  • DB-001PDO only.
  • DB-002ERRMODE_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 DELETE intent explicitly.
  • DB-007Migrations are plain DDL, IF NOT EXISTS, idempotent.
MOD

Data model conventions

  • MOD-001Public identifiers are UUIDv7 — time-ordered, InnoDB-friendly. Stored BINARY(16).
  • MOD-003created_at / updated_at are DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), stored UTC.
  • MOD-006BanNo column named data, misc, extra or metadata. Every field earns its name and type.
  • MOD-007Money columns: BIGINT in minor units plus a sibling CHAR(3) currency code.
CRY

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(). Never mt_rand, rand, uniqid.
HDR

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-002Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • HDR-004X-Content-Type-Options: nosniff
  • HDR-006Referrer-Policy: strict-origin-when-cross-origin
  • HDR-008Permissions-Policy locked down to the minimum feature set the app uses, denying the rest (e.g. geolocation=(), camera=(), microphone=(), payment=()).
  • HDR-012Server and X-Powered-By removed. No header discloses server, PHP or dependency versions.
  • HDR-013Cache-Control: no-store on any authenticated or personalised response.
HTA

Apache & .htaccess

  • HTA-004Explicit deny on /config, /storage, /db and /src.
  • HTA-007BlockerAllowOverride All must be set for the document root, or every .htaccess is 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 a RewriteCond on %{REQUEST_URI}.
  • HTA-012DirectorySlash Off; reconstruct the redirect from THE_REQUEST as an absolute https:// URL to avoid a /public/ leak + HTTP downgrade.
CSP

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 in script-src is 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=).
TOK

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.
SES

Sessions & cookies

  • SES-001Session cookie uses the __Host- prefix: host-only, Secure, Path=/, no Domain.
  • SES-003Session storage is server-side, on the filesystem, outside DocumentRoot.
  • SES-005session_regenerate_id(true) on login, logout, any privilege change, and every 15 minutes of activity.
  • SES-007BlockerNever trust $_SESSION to carry an auth decision forward. Re-validate the subject against the database on every privileged action.
RTE

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 $UP prefix is the correct pattern.
DT

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')). Never strtotime().
  • DT-004A user's zone is an IANA name (Europe/London), never an offset — offsets do not survive DST.
MON

Currency & money

  • MON-002BanStorage is integer minor units in BIGINT. £12.34 → 1234. Never FLOAT/DOUBLE.
  • MON-005An amount is a {minor_units, currency} pair. Cross-currency addition or comparison is a bug.
  • MON-007Display via NumberFormatter::CURRENCY with the resolved locale. Never hand-format £ or separators.
I18N

Internationalisation & locale

  • I18N-001Locales are BCP-47 tags (en-GB), stored per user, defaulting from APP_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.
UI

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 monolithic app.css; a manifest governs the cascade.
  • UI-013BlockerEvery CSS/JS URL carries a filesystem-mtime cache-buster (?v={filemtime}) paired with a one-year immutable Cache-Control.
TYP

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 woff2 only.
  • TYP-015font-variant-numeric: tabular-nums on data tables, metadata and aligned figures.
  • TYP-016Prose measure targets 68ch, hard-capped at 78ch.
A11Y

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-scheme where an alternative theme exists — here it does (dark default + light).
  • A11Y-011BlockerNo change ships that regresses an existing accessibility behaviour.
AUZ

Authorisation & MFA

  • AUZ-001BlockerAccess control is enforced server-side on every request. Roles are admin and member, resolved from the database, never inferred from the session alone (pairs with SES-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-004isAuthenticated() 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).
UPL

File uploads

  • UPL-001BanUploaded files are written under storage/, outside DocumentRoot. Never inside /public.
  • UPL-002BanType is decided by content sniff plus an explicit MIME and extension allowlist. The client-supplied filename and Content-Type are 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-Type and Content-Disposition. An upload directory never executes PHP.
MAIL

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 .env and 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.
GOT

Known gotchas

An accretive log — add on each trap, never delete.
  • GOT-005Fixed viewport height on mobile: use 100dvh, not 100vh.
  • GOT-008ServerTokens is server-config only; in .htaccess it 500s every request. expose_php lives in php.ini.
  • GOT-010Windows extractors skip dotfiles. After unzipping a deploy, verify .htaccess, .env.example, .well-known/ actually landed.
  • GOT-014FILTER_VALIDATE_URL validates syntax, not safety — pair it with an explicit scheme allowlist (https, mailto, tel).
LOG

Logging & observability

  • LOG-001Every request gets a correlation ID: X-Request-ID echoed 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.
AI

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.
BAN

Derived ban index

A lookup for the prohibitions above. If a ban and its source disagree, the source wins.
ProhibitionEntry
Frameworks (React, Vue, Laravel, Symfony…)STK-005
Composer, npm, build steps, CLI-only toolingSTK-006
Silent defaults for secretsCFG-004
Trusting client-side validation alonePHP-005
Rendering unsanitised HTMLPHP-008
Raw SQL interpolationDB-003
Custom-rolled cryptoCRY-001
mt_rand / rand / uniqidCRY-006
unsafe-eval in CSPCSP-004
== for token comparisonTOK-004
Sequential integer IDs in URLsRTE-004
FLOAT / DOUBLE for moneyMON-002
Inline JavaScriptUI-002
Inline style / embedded <style>UI-003
Monolithic app.cssUI-011
Third-party font hostingTYP-002
Logging secrets or PIILOG-003
Editing a file without reading it firstAI-008
Trusting a client filename or Content-Type on uploadUPL-002
Writing uploads inside /publicUPL-001
A real account password for SMTPMAIL-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.