Engineering log
Building in public.
A chronological record of the changes, decisions and milestones shaping Devafusion.
Project inception and architecture
Established Devafusion as a public technical laboratory and chose a code-first, auditable path for its web application, Azure infrastructure, delivery process, and future documentation.
Separate web and infrastructure deployments
Separated web delivery from Terraform application delivery so a web-only change does not depend on an infrastructure plan or approval stage.
SEO and accessible navigation
Improved the site's search visibility and made the shared navigation work better on small screens and with keyboards or assistive technology.
Key Vault-backed Google Search verification
Added Google Search Console domain verification for devafusion.com and devafusion.net without ever committing the verification strings to code, using a new Key Vault module and a centrally managed Azure DevOps Library variable group.
Codifying engineering standards, then proving them with secret scanning
Wrote down the engineering standards this project had been following informally, then delivered automated secret scanning as the first feature built under them, so the rules are demonstrated rather than just declared.
GA4 analytics behind a consent-first cookie banner
Added Google Analytics 4 with Consent Mode v2, defaulting to denied storage until a visitor accepts, moved Key Vault secret consumption for this GA4 ID and the existing Google verification strings from Terraform-managed resources to Terraform data sources, made an explicit App Service restart mandatory at the end of every CD pipeline that changes code or app settings, and removed several repeated literals across the Terraform configuration in favour of single-sourced locals and resource references.
A resource-to-data-source rename tried to purge two live secrets
Converting two Key Vault secrets from Terraform-managed resources to data sources deleted them from Azure instead, because Terraform only saw the resource address disappear from config. A missing Purge permission on the access policy is the only thing that stopped the deletion from becoming permanent. Recovered both secrets, cleaned up state, and closed the underlying gap in the provider configuration.
Enable Azure App Service Health check for the dev web app
Added a dedicated health endpoint and wired Azure App Service Health check to it, so a persistently unhealthy instance is detected and eventually replaced even on the current single-instance B1 plan.
Split AGENTS.md into a Hub-and-Spoke model and locked down file layout
Split the monolithic root AGENTS.md into a universal Hub plus stack-specific Spokes, added a reusable spoke template for future modules, introduced Tailwind v4 dark theme tokens, and made component/feature file layout a build-breaking ESLint gate instead of an unenforced convention.
Standardized pipeline naming to devafusion-<domain>-<ci|cd>
The web and infrastructure pipeline pairs had drifted onto two different naming schemes - fixed the half of each pair that didn't match its sibling so every pipeline name, YAML file path, and cross-pipeline resource reference now follows the same devafusion-<domain>-<ci|cd> pattern.
Three-profile accessibility theme engine without losing static generation
Added Obsidian/Editorial/Tactical colour-and-scale profiles switchable from a footer control, backed by a strictly-necessary cookie pair and a pre-hydration inline script - not a server-side cookies() read, which would have forced every page off static generation.
Establish the Vitest and Playwright testing framework
Added a 3-tier testing gate (Vitest unit/component, Playwright E2E, Docker-based visual regression) to the web application with native Azure DevOps dashboard reporting, while enforcing strict budget, speed, and environment-isolation constraints.
Onboarded devafusion.co.uk and flipped the canonical domain to .net
Brought a third App Service Domain, devafusion.co.uk, under Terraform management alongside devafusion.com and devafusion.net - matching DNS, tags, auto-renew, privacy, and a managed certificate to the existing pattern - and corrected the canonical domain to devafusion.net, redirecting both devafusion.com and the new devafusion.co.uk to it. A local terraform apply was run in violation of the pipeline-only apply rule; no live infrastructure changes actually took effect, but the process led to two new hard rules in AGENTS.md.
Split the engineering log into one file per entry
engineering-log.ts held every entry in a single shared array, so two branches in flight at once - this one and the concurrently-merged Vitest/Playwright PR - both appended to the same array tail and produced a merge conflict. Split it into one file per entry under features/log/entries/, so future concurrent branches each add a new file instead of colliding on the same seam.
Nominet .co.uk domains reject the privacy setting
The infrastructure CD pipeline's terraform apply for devafusion.co.uk failed with a 400 ("The parameter privacy has an invalid value.") after creating the DNS zone, hostname binding, and managed certificate successfully. Nominet, the UK domain registry, does not offer WHOIS privacy protection for .uk/.co.uk domains, unlike the gTLDs (.com, .net) Azure's registrar supports it for - so the azapi_resource domain resource's privacy = true was rejected outright by the registration API for this one TLD.
devafusion.com email authentication (SPF, DKIM, DMARC)
Added SPF, DKIM, and DMARC DNS records for devafusion.com so mail sent from the Microsoft 365 tenant is trusted by Google and other mail providers instead of being flagged as unauthenticated.
Fix devafusion.com SPF TXT recordset conflict
terraform apply failed to create the new SPF TXT record because Azure DNS treats every TXT value at the same name as one recordset, and a second azurerm_dns_txt_record resource at "@" collided with the existing Google site verification record already managing that recordset.
Devafusion brand mark, favicon and web manifest
Designed the Fortress Crossroads badge mark, shipped the full Next.js App Router icon/manifest/social-image file convention set, and surfaced the badge, wordmark and tagline in the site header and homepage hero so the brand identity is visible on every page, not just in browser chrome.
Sync brand mark stroke-width with updated icon.svg
The horizontal white crossroads line in app/icon.svg was tuned to a slightly thinner stroke-width (2.5 to 2.0). Propagated that same value to every other place the badge geometry is duplicated so all rendered instances stay visually identical.
Devafusion digital presence: About, Contact and Organization schema
Rewrote the About page with the full Deva Victrix origin story and engineering-principles copy, built out the Contact page stub, published GitHub/X/LinkedIn as rel="me" identity-verified links, and added Organization JSON-LD plus a sitemap fix so the site is genuinely discoverable and verifiable by Google Search Console.
About page tone, footer decluttering, and JSON-LD console warning fix
Reworked the About page copy to read as an engineering exploration site rather than a company - removed first-person/company voice, expanded the 'Dev a fusion' pun to its literal 'Develop a Fusion' meaning, cited the Deva Victrix Wikipedia entry as a real historical source, and removed the duplicate 'Get in Touch' block in favour of a single link to the Contact page. Decluttered the site footer (dropped the tagline, fixed a literal '&' rendering bug, routed the email affordance to /contact instead of a mailto: link, collapsed the always-open theme/scale controls behind a single 'Theme' disclosure, and replaced plain-text social labels with real platform logos). Also swapped the raw JSON-LD <script> tag for next/script to eliminate a React-DOM dev console warning.
Provision Azure Database for PostgreSQL Flexible Server
Added a reusable postgresql Terraform module and wired it into the dev environment as the project's first relational database, following ADR-0010's selection of Flexible Server on the Burstable B_Standard_B1ms tier. Also backfilled ADR-0001 through ADR-0009 for architectural decisions already made in this repository but never formally recorded.
PostgreSQL's first rollout hit two first-use-only Azure gaps
terraform apply for the new postgresql module failed twice in a row, each time on a completely different problem: first an AuthorizationFailed on Microsoft.DBforPostgreSQL/flexibleServers/write because the pipeline's custom role had never included that action, then (once fixed) a MissingSubscriptionRegistration because the subscription itself had never activated the Microsoft.DBforPostgreSQL resource provider. Both were first-use-only gaps - nothing wrong with the module - fixed manually and out of band, and the third apply succeeded end to end (15 added, 4 changed, 0 destroyed), creating the server and all 14 web-app-outbound-* firewall rules cleanly. A ClientIPAddress_* rule was also observed on the server between the second (failed) and third (successful) apply. It was mistakenly deleted on an unverified assumption that it had been manually added; the successful apply's own log never created or referenced that name, so its origin was never actually confirmed. It has since been restored (same name, same IP address) once the deletion was recognized as a mistake, since a resource this project did not create is not this project's decision to remove.
Cost circuit breaker for the PostgreSQL Flexible Server
Closed out ADR-0010's deferred 'cost circuit breaker' consequence: a Consumption Budget on the app resource group now warns at 80% actual spend and, at 100%, triggers an Action Group wired to an Automation Runbook that stops the PostgreSQL Flexible Server. Everything added stays inside Azure's free tier - no new billable resource.
Cost circuit breaker's first apply hit two more first-use gaps
terraform apply for the cost-circuit-breaker module failed on two independent problems in the same run: a MissingSubscriptionRegistration for Microsoft.Automation (never registered on the subscription, same class of gap as the earlier PostgreSQL provider registration), and an availability-zone conflict on the PostgreSQL server itself, unrelated to the new module - Azure had auto-assigned a zone at creation time that the postgresql module never declared, so every subsequent plan tried to reconcile a diff the API rejects outright.
Automation webhook rejected a far-future expiry time
The cost-circuit-breaker module's apply got past the earlier provider-registration and zone blockers, created the Automation Account, Runbook, and role assignment cleanly, then failed on the budget-trigger webhook: Azure's Automation webhook API returned a plain 'Invalid expiry time' 400 for an expiry_time set decades out (2099), with no documented ceiling given anywhere in the REST reference. Replaced the hardcoded far-future literal with a bounded, variable expiry 10 years out, and documented rotating it as a deliberate operational task rather than guessing at the undocumented real limit.
The 10-year webhook expiry fix was still over the real ceiling
The previous fix's 10-years-out default (2036) hit the exact same 'Invalid expiry time' 400 it was meant to solve - the assumption that 10 years was safely inside Azure's undocumented ceiling was wrong. Rather than guess a third time, tested the real boundary directly against the Automation webhook REST API: 2027, 2028, 2031, 2033, and 2035 (up to roughly 9 years out) all succeeded; 2036 (about 10 years and 3 months out) and 2099 both failed identically. The real ceiling sits somewhere in that narrow window, so the module now defaults to 9 years out - comfortably inside it, not balanced on the edge.
Database tooling: Drizzle ORM behind a repository pattern
Set up Drizzle ORM, drizzle-kit migrations, drizzle-zod validation, and spec-only OpenAPI generation, all proven against a local in-memory PGlite Postgres rather than a real network database. No production data was touched - the engineering log stays on static files for now; this slice proves the tooling and the SOLID repository pattern that will carry it, and any future OAuth/contact-form data, into PostgreSQL.
Local Docker Postgres workflow for testing drizzle-kit migrations
Documented and proved, end-to-end against a real container, the local workflow for testing a drizzle-kit migration before it ever runs unattended in CD - and fixed a real drizzle-kit/server-only incompatibility found while doing it. No table was migrated to the live Azure Postgres server - that decision (and the identity/auth model it depends on) stays deliberately open.
Identity foundation: Better Auth + self-hosted, encrypted TOTP MFA
Built the identity layer's foundation - Better Auth for session/credential/OAuth core, its CLI-generated Drizzle schema against Postgres 16, citext for case-insensitive email, and a self-built TOTP MFA module with application-layer envelope encryption - after a live discussion surfaced that Auth.js has merged into Better Auth and that Better Auth's own twoFactor plugin stores its TOTP secret as plain text with no read-side decrypt hook. No registration/login UI or the role-gated visibility feature this was originally motivated by are built yet - this slice is the identity foundation only.
CD pipeline runs the first real database migration, gated by manual approval
Wired pipelines/cd/web.yml to actually apply drizzle-kit migrations to the live psql-devafusion-dev-uks server - a temporary named firewall rule for the CD agent, manual approval on a dedicated Azure DevOps environment, and the current migration SQL printed in CI before that approval gate is ever reached. Went through three design passes in one sitting: split detection/preview into a separate stage using a cross-stage output-variable condition whose exact syntax couldn't be verified, then discovered and removed an unsound git-diff-based gate in favour of running drizzle-kit migrate unconditionally, then diagnosed and fixed a real first-production-run failure (firewall-rule propagation delay) using a live connection test to rule out a competing hypothesis (an SSL deprecation warning) before committing to a fix - and, along the way, caused and immediately rotated a real credential exposure.
Registration and login UI, wired to the existing Better Auth backend
Built email/password sign-up, log-in, and log-out UI on top of the identity backend from the previous slice - the first user-facing consumer of Better Auth. A review before opening the PR correctly flagged that registration triggers real UK GDPR obligations this slice hadn't addressed, so the scope grew to include self-service account deletion (Article 17) and data export (Article 15) rather than shipping registration ahead of them. Deliberately still excludes MFA enrollment/challenge UI - no account has MFA enabled and that is a separate future slice. Caught and fixed a real rendering regression before it shipped: an initial server-side session read in the header would have forced every page on the site into dynamic rendering.
Bluesky added as a verified social identity
Published Bluesky (@devafusion.net) alongside GitHub, X and LinkedIn as a rel="me" identity-verified link, and provisioned the AT Protocol domain-handle verification DNS record so bsky.app can confirm ownership of devafusion.net.
Client-level auth hardening: rate limiting, Turnstile, GDPR transparency (no WAF/Front Door)
Hardened the registration/login/account-recovery surface against automated abuse without a Web Application Firewall or CDN in front of the App Service - a deliberate cost decision, not an oversight. Enabled Better Auth's own database-backed rate limiter, added a self-built limiter for routes outside its router, added Cloudflare Turnstile bot protection, a stateless timing heuristic as an independent second signal, and a new /forget-password + /reset-password flow (email delivery itself is stubbed, since this project has no email infrastructure yet). Also closed a real UK GDPR transparency gap - /sign-up and /log-in collected data with no in-context privacy notice or link to /legal.
Fixed two live bugs from the auth-hardening deploy: Turnstile build-time key, CSP blocking GA4
The previous slice's Turnstile widget and CSP header both shipped broken on the live site, confirmed directly from the browser console: sign-up/log-in were unusable because the widget never rendered, and GA4 analytics were silently blocked. Both root causes were confirmed empirically by inspecting real compiled build output and real console errors, not assumed from documentation.