# What changed in this build, and why

---

# Build 4.0 — article settings, a month calendar, a richer review screen, and measurement

*20 August 2026. Verified against a live MariaDB 10.11 (all nine migrations on a
fresh schema and re-run to confirm they are no-ops), by 397 assertions across
six suites, by 35 Chromium interaction checks at 1440px and 390px in both
themes, by contrast measurement on every new component, and by an adversarial
review of all new code that found nine real defects — all fixed and each one
covered by a test. Upload steps in `UPLOAD-TO-GODADDY.md`.*

This build closes the gap against the AutoSEO screens you sent, in the order you
chose: **Article Settings and grounded writing**, then **the month calendar and a
richer article page**, then **the strategy mindmap, Analytics and backlink
comparison**.

---

## 4.0.1 Article settings, per website

A settings screen with eight tabs, one row per site, replacing values that were
previously constants in the code.

* **Publishing cadence** — 7, 5, 3, 2 or 1 article a week, matching AutoSEO
  exactly, defaulting to 2. The days are derived, not stored: 5 means Monday to
  Friday, 3 means Monday/Wednesday/Friday, 2 means Tuesday/Thursday.
* **Article length** — 3,000 / 2,500 / 2,000 / 1,500 words.
* **Furniture** — hero image, key takeaways, table of contents, external links,
  links in a new tab, YouTube embed, infographic. Each is a real switch that
  changes the brief the writer is given, not a label.
* **Business context** — upload company documents (`.txt`, `.md`, `.pdf`,
  `.doc`, `.docx`) and the writer is grounded in them. What you sell and what
  you explicitly do **not** sell are separate lists, because the failure that
  matters is an article recommending a service the company does not offer.
* **House rules, call to action, author box, disclaimer, image prompt, logo.**

### Reading the uploaded documents

`.docx` is a zip of XML, so paragraphs are recovered before the tags are
stripped — otherwise every sentence in the file runs together into one line.
PDFs are harder and the extractor grew three times during this build:

* `/Filter [/ASCII85Decode /FlateDecode]` is a **chain**, not one filter.
  Attempting Flate alone returned nothing for anything produced by reportlab.
* LibreOffice PDFs store text as **hex glyph indices against a subsetted font**,
  which needs the `/ToUnicode` CMap parsed to recover characters at all.
* CMaps are **per font resource**. A merged one decoded a heading through the
  body font and produced `Establihedi 1d98 .liIhSSO`. They are now keyed by
  `/F1`, `/F2` and tracked from the `Tf` operators in a single ordered pass.

---

## 4.0.2 The calendar is a month

It was a vertical list of week cards. Finding an empty Thursday meant counting.
It is now a month grid, each day holding its slots, each slot carrying its own
actions — write, review, view, publish, or pick a topic — so filling four empty
slots is four clicks rather than eight page loads.

---

## 4.0.3 The article review screen

* **Key takeaways** and a **table of contents**, both driven by the per-site
  settings, and the contents is built from the rendered heading anchors so an
  entry cannot point at a section that does not exist.
* **The target keyword is highlighted** in the body — the first few uses only,
  never inside a heading, link or code block. The marks are review furniture:
  they are not in the export and not in the published post.
* **A metadata card** — meta description, meta keywords and CMS tags — as its
  own form. Fixing a keyword is not an edit to the article, so it no longer
  withdraws an approval the way saving the body does.
* **Export as HTML, Markdown or Word.** The Word file is a genuine `.docx` (a
  zip of OOXML parts), not an HTML file wearing a `.doc` extension: both open in
  Word, but only one opens in Google Docs, Pages and on a phone.
* **Three feedback buttons**, kept forever and never shown to the model. After
  forty articles this answers "which briefs keep producing drafts we mark
  wrong" from data instead of memory.

---

## 4.0.4 Tags now actually reach WordPress

Two defects, found together.

The publisher was sending the country code and the target keyword as tags —
so `AE` and `charge air cooler` appeared on the live site — and ignored the
tags column entirely. And the WordPress driver dropped `tags` on the floor.

WordPress's REST schema for a taxonomy field is `items: {type: integer}`, so
posting names fails validation and takes the whole article with it. Tag names
are now resolved to term ids first: looked up by `slug` (not `search`, which is
a fuzzy match on both name and slug — asking for "seo" returns "Technical SEO"
and tags your article with somebody else's category), created if absent, and a
duplicate-name conflict is read back from `data.term_id` so two concurrent
publishes do not lose a tag between them.

Nothing here can fail a publish. A missing tag is cosmetic; an article that did
not go out because a taxonomy call was refused is not. The whole step is also
bounded to 25 seconds, because fifteen tags against an unresponsive WordPress
was minutes of waiting spent before the article was even sent.

---

## 4.0.5 The content strategy map

Every keyword cluster as a circle: sized by monthly demand, coloured by how far
the work has got, grey where nothing is planned. Server-rendered SVG, so it
prints, pastes into a slide deck, and survives being emailed.

The layout is a fixed radial, not a force-directed graph. Force layouts look
impressive and move every node whenever the data changes, so nobody can compare
this month's picture with last month's. The highest-demand cluster is always at
twelve o'clock.

---

## 4.0.6 Analytics — the only screen that reports reality

Everything else in this app reports intent: estimated volumes, difficulty
scores, priority rankings, all bought from an API. This screen reports clicks
and impressions counted by Google on your own property, and it is the number
that settles whether any of the rest was worth doing.

Each article gets a verdict rather than a row of figures: *still settling*,
*not being shown*, *ranking too low*, *shown but not clicked*, *working*. The
fourth is the one worth having — it means the page is being surfaced and the
title is not earning the click, which is a ten-minute fix on the same screen
that reports it.

Four things it refuses to do:

1. **It never shows a window ending today.** Search Console lags two to three
   days, so a "28-day" window ending today is a 25-day window with three empty
   days on the end, and everything published this week looks like a failure.
2. **It never says a young article has failed.** Under 21 days it is marked as
   still settling and left out of the averages.
3. **It does not report an "AI traffic" figure**, because there is no such
   field. Google counts AI Overview and AI Mode clicks as ordinary organic
   Search clicks, in this API and in GA4. Any number here labelled AI traffic
   would be invented.
4. **It stores what it fetched**, so the screen still works when the quota is
   spent or the token has expired.

---

## 4.0.7 Backlink comparison — and what it deliberately is not

Your referring domains against each competitor's, from the DataForSEO Backlinks
API, with the gap explained in a sentence and the honest options stated: link
building is quarters of trade press, supplier listings and citable data, and the
one fast route — buying links — violates Google Search Essentials, with the
penalty landing on the company rather than on this tool.

**AutoSEO offers a link exchange. This does not, and cannot.** A link exchange
needs a network of other people's websites to swap between; AutoSEO has one
because it has thousands of paying members. An internal tool for one company has
nobody to swap with. Building a version of that feature here would mean either
buying links or pretending, so this measures the gap and stops.

**The 0–100 figure is DataForSEO Rank**, named in full everywhere it appears. It
is not Moz Domain Authority, not Ahrefs Domain Rating and not Majestic Trust
Flow. Those are other vendors' trademarked metrics computed from other vendors'
indexes, and the numbers do not agree — relabelling one as another is a
licensing problem and a way to make a decision on a figure that does not say
what the reader thinks it says.

---

## 4.0.8 Defects found by review and fixed

An adversarial review of the new code found nine real problems. All are fixed
and each has a test.

1. **The strategy map understated every cluster's demand.** `SUM(DISTINCT)` adds
   up the set of distinct *values*, not the values of distinct rows, and
   DataForSEO returns the same rounded volume for near-synonyms constantly — so
   a three-keyword cluster at 1,400 searches each reported 1,400 instead of
   4,200. It fed the label, the circle size *and* the ordering, so the picture
   ranked the clusters wrongly as well.

2. **The backlink refresh reported a cost that was not what was charged, in both
   directions.** It wrote a hardcoded `$0.098`. A refresh repeated inside six
   hours was served from cache, cost nothing and still claimed ten cents; a
   *failed* refresh recorded zero after four calls had gone out and been billed;
   and the failure message said "nothing was charged" in the same breath as
   writing the row. The adapter now tracks what was actually metered.

3. **A partial result looked complete.** The daily budget cap is checked per
   call, so it can trip between the first bulk endpoint and the fourth — leaving
   referring domains populated and Rank and spam score blank, with no
   explanation anywhere. Partial results are now recorded and shown as such.

4. **An edit saved during a publish was silently thrown away.** The screen said
   "nothing can be changed until it finishes"; the form was live anyway. The
   publisher snapshots the article before claiming it, so the save landed in the
   database, never reached the CMS, and became permanent when the job marked the
   article published. Both the route and the form now refuse `publishing` and
   `drafting`, and say why.

5. **One failed refresh hid the last good link comparison entirely** — the
   opposite of why snapshots exist. The newest snapshot *with figures* is now
   shown, with the newer failure reported beside it.

6. **The competitor median counted companies that returned nothing.** It claimed
   "a median of seven competitors" when two answered. It now stores and reports
   the number that actually fed the median.

7. **Search Console rows were never retired.** Google omits a page entirely when
   it had no impressions, so a page that pulled 412 clicks last month and
   nothing since kept reporting 412 forever — and it was summed into the
   headline "clicks, last 28 days", which therefore never came down.

8. **The revision list disagreed with the page header.** The word count was
   computed in SQL as spaces + 1, which ignores newlines and counts `|` and `##`
   as words: the header said 199 words and the History list said 201 for the
   same text. Both now use the same function.

9. **The HTML export was not self-contained**, despite saying so on the button.
   The hero image was a relative reference to a file that was not in the
   download. It is now inlined as a data URI, or omitted.

Smaller, also fixed: an empty slug produced a download called `.docx`; a meta
keyword over 80 characters vanished silently; `münchen.de` and punycode domains
were quietly dropped from the competitor median; the URL used to join Search
Console rows was normalised slightly differently in PHP and in SQL, which would
have made an article read "no data" forever; and an article published yesterday
was described as "published today".

---

## 4.0.9 What is in the database

Migration `009_settings_content.sql` adds `site_settings`, `site_document`,
`site_offering`, `site_internal_link`, `article_feedback`, `backlink_snapshot`,
`backlink_domain` and `gsc_performance`, plus `meta_keywords`, `cms_tags`,
`takeaways_json` and `settings_json` on `article` and `note` on `competitor`.

Every column addition is guarded by an `information_schema` check, so the file
is safe to run on a database that has already seen part of it and runs on MySQL
as well as MariaDB (`ADD COLUMN IF NOT EXISTS` is MariaDB-only). Running the
migrations twice is a no-op; this was verified rather than assumed.

---

# Build 3.0 — competitor analysis by country, an editorial calendar, and one-click publishing

*19 August 2026. Verified against a live MariaDB 10.11 (all eight migrations
applied on a fresh schema and on an upgrade path seeded with the exact
duplicate-key state that would have broken it), by rendering every template in
Chromium at 1440px and 390px in both themes, and by 292 assertions across five
suites. Upload steps in `UPLOAD-TO-GODADDY.md`.*

This build answers the brief: **competitor analysis per country**, then **article
recommendations for SEO and AI SEO**, on **a calendar of two articles a week per
site**, with **review before anything goes live** and **one-click publishing** to
the site's own CMS — including a generated image that carries no logo, brand
name or product name.

---

## 3.0.1 What actually happens now

1. **Pick the markets.** Analyze → tick countries. The list is loaded from
   DataForSEO's free `locations_and_languages` endpoint, because a country code
   is not enough to query a SERP — DataForSEO needs the numeric Google
   geo-target id, and those cannot be derived. Nothing is hardcoded, so any
   country the API supports is available. Each country is analysed in its own
   language, and in Arabic as well where that is the second language.

2. **Analyse.** Two passes: `competitors_domain` (who ranks on the same
   keywords we do) and `serp_competitors` (who owns the SERPs for our head
   terms). The second catches the rival who beats you on the terms that matter
   but has little overall overlap — often the sharpest one, and invisible to
   the first pass.

   Every domain found is **classified before it is scored**: directory,
   marketplace, aggregator, publisher, OEM principal, government body, or a real
   competitor. Search "chiller maintenance dubai" and the first page is
   yellowpages.ae, a listicle, Alibaba and two lead-generation sites. Treating
   those as rivals produces a strategy of trying to out-rank a directory.
   Classification is blocklist first, then patterns, then *assume direct
   competitor* — a false "real competitor" costs one wasted article, while a
   false "directory" silently deletes a real rival from the analysis.

3. **Find the gaps.** Keywords the direct competitors rank for and you do not,
   or rank badly for. Enriched with volume, difficulty, CPC and intent, then
   clustered so the same article is not commissioned three times.

4. **Score and explain.** Priority is value × achievability × intent weight ×
   proof ÷ effort. Every recommendation carries a plain sentence saying why —
   *"We do not rank for 'chiller maintenance dubai' at all. rival.ae ranks #3
   for it, six competitors cover it, it is commercial intent, and it is searched
   about 880 times a month."*

5. **Schedule.** Two slots a week per site, Tuesday and Thursday. Midweek
   outperforms Monday and Friday for B2B and it leaves Monday free to move
   something forward when a slot slips. A slot exists before it has an article
   in it — an empty slot next Tuesday is a question with a deadline; a backlog
   is not.

6. **Draft.** Anthropic writes to a brief built from the actual analysis:
   demand, the competitor pages to beat, an outline, the questions to answer,
   the internal links that genuinely exist, SEO requirements and AI-SEO
   requirements (answer-first paragraph, one claim per sentence, question-shaped
   headings, a specification table, a 4–6 question FAQ, Article + FAQPage
   JSON-LD).

7. **Illustrate.** OpenAI generates a hero image. The exclusion — no text, no
   logos, no brand names, no product names, no watermarks, no signage, no
   recognisable faces — is in the prompt, in a `NEGATIVE` constant, and the
   client's own name is stripped out of the scene even when it arrived from a
   brief or a human. There is no API setting that guarantees this, so the review
   screen says so and asks you to look.

8. **Review.** Every sentence and table row containing a number is listed
   separately for checking, because the failure that matters is a confident,
   specific, wrong figure. Nothing publishes until a person presses Approve, and
   editing the text after approval withdraws the approval.

9. **Publish.** One click to WordPress, Shopify, Wix or your own endpoint, with
   the image. Whether that lands as a live post or a draft is a per-site
   setting, and it starts as a draft.

---

## 3.0.2 What each platform can actually do

Researched against each vendor's current API documentation before a line of
client code was written.

| Platform | Publishing | Image | Notes |
|---|---|---|---|
| **WordPress** | REST + Application Passwords | Uploaded and set as the featured image | Verifies the image actually attached — a theme without post-thumbnails support ignores `featured_media` silently |
| **Shopify** | Admin GraphQL `articleCreate` | `stagedUploadsCreate` + `fileCreate` | Needs `write_content` and `write_files` on a custom app |
| **Wix** | Draft Posts API | **Not automatic** | Wix has no publish path in its API and its body format is a bespoke JSON structure, not HTML. The draft is created; you add the cover image and publish inside Wix |
| **Your own PHP / static site** | JSON POST, Micropub-shaped | Sent inline as base64 | A ~30-line receiver is printed on the Publishing page, with bearer token and HMAC signature |
| **Squarespace** | **Impossible** | — | Its API keys cover Commerce, Orders, Inventory, Products and Webhooks. There is no blog permission and no way to grant one. The only import route is a one-time WordPress `.xml` upload that does not sync. Those articles come out as a download bundle |

Squarespace is stated rather than hidden. A tool that quietly does nothing is
worse than one that explains why it cannot.

---

## 3.0.3 The defects found and fixed before release

Two independent adversarial reviews were run against the new pipeline, then a
third against the fixes. The ones worth recording:

**Publishing could never have succeeded.** The route set the article's status to
`publishing` at queue time; the publisher refuses anything that is not
`approved`. Every publish job would have failed its own precondition and left
the article stuck in `publishing` forever, showing *"Disabled until the article
is approved"* on an article that had just been approved.

**Up to six duplicate live blog posts.** `createPost` allowed one HTTP retry —
a read timeout after WordPress has already inserted the row is indistinguishable
from a failure, so the retry publishes a second post — and the queue re-issued a
failed publish job up to three times. Now: no HTTP retry on any post creation, a
publish refusal is recorded rather than thrown, and a `publish_attempt` row is
written **before** the request leaves, marked unresolved, so a process that dies
mid-flight still leaves evidence that something was sent. The next click is
blocked with an explanation and a release button, rather than silently posting
the article twice.

**A double-click cost a dollar.** Analysis, drafting and image generation all
spend money on their first tick and had no idempotency. Now every one carries a
dedupe key with a **UNIQUE index** behind it — the `SELECT` makes the common case
quiet, the index is what actually stops the second job, because two clicks a
millisecond apart both pass a `SELECT`.

**Anthropic and OpenAI spend was neither metered nor capped.** Neither provider
passed a cost, so `api_usage.cost_usd` was always zero and the daily-budget guard
was never reached — and had no arm for them if it had been. Now both are metered
per attempt, corrected to the real figure when the usage block comes back,
capped by `ANTHROPIC_DAILY_BUDGET_USD` and `OPENAI_DAILY_BUDGET_USD`, and
re-checked before each retry so one request cannot book three times its
allowance. A 4xx rejection un-books its estimate; a 429 was never billed and is
never booked.

**A budget stop was reported as a fact about your business.** When the
DataForSEO cap stopped a pass mid-run, the empty result was explained as *"Either
the site has no organic presence in this market yet, or the domain is wrong."*
That is a claim about a customer's business, invented, because a spending limit
was reached. The client now reports the stop, and the screen says so.

**"An analysis never overwrites a manual classification" was false.** The UI said
it in three places; the upsert overwrote `class` unconditionally. It is now true,
and there is a test that fails if it stops being.

**Editing an approved article did not un-approve it.** Approval means a human
read *exactly this text*. The moment the text changes that is no longer true.

**A failed draft cost full price three times** and left three "failed" articles
on the calendar for one slot. Drafts are no longer auto-retried — the model
refusing a prompt three times costs three times to discover — and the row is
reused.

**The new columns would never have reached your live database.** The migrator
records a file by name and never re-reads it, so adding columns to an
already-applied `007` changes nothing on an existing install. The first click on
Publish would have raised *"Unknown column 'dedupe_key'"*. They are now in
`008_publish_safety.sql`, which also clears the stale keys that would otherwise
make the new UNIQUE index impossible to create.

Around thirty further defects — dead ends where every button was disabled with no
explanation, instructions naming controls a read-only account cannot see,
"1 countries", "Check these 0 figures", "5 open findings" above a list of article
ideas, a calendar telling you to press a button that did not exist, a brief
demanding six internal links when none existed and an author byline the writer is
forbidden to invent — are listed in `UI-UX-REVIEW.md`.

---

## 3.0.4 What is new in the package

**Migrations**

- `007_analyze_content.sql` — `site_country`, `analysis_run`, `keyword_gap`,
  `content_calendar`, `article`, `article_revision`, `article_image`,
  `cms_connection`, `publish_attempt`. New tables only.
- `008_publish_safety.sql` — the idempotency key and the publish-safety columns,
  as guarded ALTERs that run on MySQL and MariaDB alike, plus the data repair
  that makes the UNIQUE index creatable on an install that already ran 007.

**Screens** — Analyze, Competitors, Opportunities, Calendar, Article, Articles,
Publishing.

**Settings** — `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`,
`ANTHROPIC_DAILY_BUDGET_USD`, `OPENAI_API_KEY`, `OPENAI_IMAGE_MODEL`,
`OPENAI_IMAGE_SIZE`, `OPENAI_IMAGE_QUALITY`, `OPENAI_IMAGE_FORMAT`,
`OPENAI_DAILY_BUDGET_USD`. All documented in `.env.example`.

**The worker gained** `analyze.run`, `calendar.plan`, `article.draft`,
`article.image` and `article.publish`, and takes a comma-separated `--queue`.

---

## 3.0.5 What it costs

Measured, not estimated.

| Action | Cost |
|---|---|
| One country/language analysis | roughly **$1** in DataForSEO credit |
| One 1,500-word article | a few cents (Anthropic, ~2.5k in / ~4k out) |
| One hero image | **$0.041** (gpt-image-2, 1536×1024, medium) |
| Two articles a week for one site | under **$1 a month** in text and images |

The three daily caps in `.env` are hard stops, not warnings, and a run stopped by
one says so rather than reporting an empty result.

---

## 3.0.6 How it was verified

| Suite | Assertions | What it proves |
|---|---|---|
| `tests/content_test.php` | 154 | Markdown, response parsing, schema extraction, clustering, gap detection, calendar dates, credential encryption, the brand stripper, the "did the post happen anyway?" decision, prompt-versus-brief consistency |
| `tests/integration_test.php` | 34 | Against a **live MariaDB**: the UNIQUE index actually refuses a racing insert; the SET-clause ordering in the un-approve statement genuinely clears `reviewed_by`; the publish claim is atomic; a released block preserves what the CMS said; a manual classification survives a re-analysis |
| `tests/auth_test.php` | 25 | Roles, capabilities, lockout, password rules, redirect safety |
| Playwright, three suites | 100 | Filters, sorting, the driver switch swapping `name` attributes so a hidden Shopify token is never posted with a WordPress password, the unsaved-changes guard, print CSS, and which buttons are live in each article state |
| Render harness | 55 pages | Every template with fixtures, including every empty and failure state, checked for balanced tags and leaked PHP |

Plus: all eight migrations run against a real MariaDB on a fresh schema **and**
on a reconstructed pre-fix database seeded with duplicate dedupe keys, to prove
the upgrade path repairs itself instead of failing halfway.


---

# Build 2.0 — accounts, and the UI the design system was already built for

*19 August 2026. Verified by rendering every template in Chromium at 1440px,
1280px and 390px, light and dark, and by reading computed styles. Full reasoning
in `UI-UX-REVIEW.md`; upload steps in `UPLOAD-TO-GODADDY.md`.*

## 2.0.1 There was no login

`POST /api/sites/{id}/audits` and `POST /api/findings/{id}/status` accepted any
request carrying a session cookie the server itself had just issued. A `noindex`
meta tag is a request to Google, not access control.

**Now:** `app_user` / `auth_event` / `user_pref` (migration `006_users.sql`, new
tables only — nothing existing is altered). Three roles: Administrator, Analyst,
Viewer. Lockout after six failures, eight-hour idle timeout, session-id
regeneration on sign-in, an append-only sign-in log, and identical wording for
"no such account" and "wrong password" with a decoy hash verified in the first
case so the two do not differ measurably in time.

The guard runs **before dispatch**, not inside handlers — a route added later is
protected unless it is named in the public list. Viewer restrictions are enforced
server-side; hiding buttons is a courtesy, not a control.

No default account and no password-reset email. An empty `app_user` table puts
the app into a one-time "create the first administrator" mode.

`migrate-web.php` now refuses anonymous visitors once any account exists, and the
app nags administrators by filename until `setup-env.php`, `migrate-web.php` and
`preflight.php` are deleted.

## 2.0.2 Half the component library was never connected to a page

`app.css` already contained filter bars, search fields, tabs, segmented controls,
skeletons, progress bars, modals and drawers. None were rendered anywhere, while
the pages made readers do the work those components exist to do.

**Now wired up:** findings filter bar (free-text across code, title, URL and
remediation; segmented severity filter with live counts; "only with traffic"
switch; honest "12 of 60 shown" counter, filtering rows already in the DOM);
determinate progress during a crawl; sortable table headings; site search;
sidebar site filter; a keyboard shortcut sheet.

## 2.0.3 Nothing said what the numbers meant

**Now:** a summary band on the dashboard and every site page — one heading, one
paragraph, one next step. It names the weakest site and links to it; says whether
a score is *capped by a hard gate* or merely low; names the weakest category;
says which finding to start with.

## 2.0.4 Three CSS bugs that were live on the site

Each was a later rule silently overriding an earlier one. None would surface in a
code review; all three were measured in the browser.

1. **Every input carried the browser default `2px inset #767676`.** The reset
   clears `border` on `button` but not on `input`, so the token ring was drawn
   *on top of* a black border. Fixed with `border: 0` on `.input, .select,
   .textarea`.
2. **The 104px score gauge collapsed to 48px** wherever it shared a flex row with
   text, because `img, svg { max-width: 100% }` applied to it. The ring and the
   figure overlapped. Now pinned.
3. **The 84px sparkline rendered 236px wide**, spraying across the stat tile:
   `.sparkline { width: 100% }` is declared after `.stat__spark { width: 84px }`.
   Fixed with a same-element combo selector that survives reordering.

## 2.0.5 Found by reviewing 2.0.1-2.0.4 adversarially

Each of these was live in the first cut of this build and fixed before it
shipped. Almost all were a number or a rule that was true in the code and false
on the screen.

- The site page counted `$findings` (capped at 60 by the query) for the summary
  sentence, the stat tile and every filter chip - so a site with 212 findings
  would have read "60 open findings" while the dashboard's uncapped tile said
  212 on the previous screen. Counts now come from SQL over the whole run.
- **Traffic at risk** summed `affected_clicks` per finding, so a URL carrying
  three critical findings contributed its clicks three times. Now `SUM` over
  distinct URLs.
- `display: revert` on `.finding[hidden]` in the print stylesheet reverts to the
  user-agent origin, where `[hidden] { display: none }` lives - so a filtered
  view printed as the filter, not as the list, under a total that said otherwise.
- Table sort took its column index from the filtered list of sortable headers
  rather than from the row, so "Open critical + high" sorted the Band column,
  got `NaN` for every row, reordered nothing, and announced `aria-sort` anyway.
- The "nothing is consuming the queue" alarm fired on a correctly installed
  five-minute cron, and outranked the useful advice in the next-step chain.
- `setup-env.php` was an unauthenticated `.env` writer, which made the
  `migrate-web.php` gate worthless: repoint the app at an empty database and it
  offers "create the first administrator" to the next visitor. All three
  installer tools now share `config/installer-guard.php`, backed by a
  `var/.installed` marker so the gate can tell a fresh install from an outage.
- `next=` was an open redirect via a backslash: PHP does not treat one as a path
  separator, the WHATWG URL parser in every browser does.
- `manage_settings` was declared in the capability table and enforced nowhere,
  so a viewer could rebind the org's Search Console account to their own
  property - and drive a billable DataForSEO call on every page refresh.
- The CSRF token was reused as the Google OAuth `state`, publishing the token
  that protects every mutating route into browser history and Google's logs.
- The decoy password hash was hardcoded at cost 12 against PHP 8.0-8.3's default
  of 10, making the "no such account" path four times *slower* - a timing
  mitigation that reintroduced the leak it was written to close.
- Grammar and dead ends: "of which 1 is critical" after "one open finding";
  "All 1 measured site are"; "0 websites are configured and ready to audit"
  during a database outage; a compulsory change-password screen with no sign-out
  link; a 403 that told an Analyst they were read-only, under a heading reading
  "Nothing here".
- Tooltips were dismissed by the scroll that brought their element into view.

## 2.0.6 Smaller corrections

- Findings marked fixed recorded `assigned_to` from the **request body**; now
  from the session.
- `/api/health` published queue depth, PHP build and the MySQL error message to
  anonymous callers; now up-or-down only unless signed in.
- The run poller had no branch for `inconclusive` and fell through silently.
- Evidence JSON printed expanded under every finding, burying the remediation
  text; now behind a `<details>`, still expanded in print.
- "Full JSON" replaced by **Export CSV**, with a UTF-8 BOM so Excel on Windows
  does not mangle it.
- The mobile drawer covered the control that closed it: no scrim, no
  tap-outside, no Escape. All three now.
- The collapsed desktop sidebar was a column of unlabelled glyphs; names now
  appear on hover.
- Sites listed in database ID order; now worst-first.
- Raw UTC timestamps; now relative, with the exact stamp in `title` and in print.
- `/` focused a search box that did not exist anywhere in the app.
- Sidebar showed a hardcoded name and job title.
- No print path. The site page is now the report — chrome dropped, evidence
  expanded, filtered rows un-hidden so a filtered view cannot print as if it
  were the whole list, link targets printed.
- Accessibility: `aria-expanded`, `aria-pressed`, `aria-sort`, `role="img"` with
  text labels on the sparkline and category meters, visually-hidden text in every
  matrix cell, and a real tooltip element replacing `title=` (which never appears
  on keyboard focus).
- `tests/auth_test.php` — 25 assertions, no database required.

---


For your developer. Everything below was found by running the app against real
Apache 2.4 + mod_php 8.3 and real MariaDB 10.11, and against a real site — not by
reading the code.

---

## 1. An audit that could not fetch the site scored 100/100

**The worst bug in the build.** Found by auditing a live domain that answered
`403` to the crawler. The run finished, was marked `completed`, and published:

```
score=100.00   0 findings   risk 0   strength 100
```

One line caused it:

```php
$overall = $den > 0.0 ? round($num / $den, 2) : 100.0;
```

With nothing fetched, every check is "not applicable", the weighted denominator is
zero, and the fallback published a perfect score. A site that is down, DNS-broken,
behind a login, or blocked by a WAF was indistinguishable from a flawless one —
and that is the report someone would have taken to a meeting.

**Now:** a run with no 2xx response is `inconclusive`. No overall score, no risk,
no strength, no category rows are written at all — `score.value` is `NOT NULL` by
design, so "inconclusive" is the *absence* of the row rather than a null inside
it. The run carries the reason:

> No page returned a usable 2xx response (1 request made, last status: 403). A WAF,
> geo-block, HTTP auth or login wall is the usual cause — allowlist the crawler
> user agent or its IP, then re-run.

The site page shows that text in a banner, the dashboard says **Inconclusive**
rather than **Not audited**, and the gauge shows `—`. Locked down by
`tests/scorer_test.php` (17 assertions), including the subtler variants: a crawl
of redirects only, a crawl that fetched pages but evaluated no check, and a crawl
where only info-severity checks fired.

`003_inconclusive.sql` also retires scores already published this way. On a fresh
install it does nothing.

## 2. AI crawler access carried zero weight in the score

`AiAccessProbe` wrote `finding` rows but never a `check_result` row, and the
scorer only sums what it can see. So a site that disallowed `OAI-SearchBot`
displayed a **critical** A03 finding and lost **zero** points — the `ai_access`
category (weight 8) was absent from the score entirely. The AI SEO audit was
reporting but not scoring, which is the one thing it was built for.

**Now:** eligibility is counted per check, decided *without* looking at the
verdict (otherwise prevalence is always 100% and the sublinear curve does
nothing). On the test site: `ai_access` scores 83.33, A03 at prevalence 1.00
carries its full weight of 10, and Risk moved 38.9 → 58.9.

## 3. Info-severity checks were quietly costing points

The scorer's contract says `'info' => 0.0, // never scored; reported only`. The
seed gave all four info checks `weight 0.5`, so the weight won — and sites lost
points for things the seed's own remediation text calls legitimate:

- A09 — "Blocking training crawlers is a legitimate business choice with no effect on current AI search visibility."
- A18 — "Cloudflare pay-per-crawl is active. Intentional monetisation, not an error."
- D10 — "Do not score missing schema as an AI visibility defect."

You were being penalised for deliberately blocking GPTBot. Fixed in the seed and
in `005_info_weight.sql`. Caught by a test, not by reading.

## 4. 79 of the 134 checks have no evaluator — now stated, not hidden

`002_seed.sql` loads 134 check definitions. 55 have working evaluators. The other
79 write no `check_result` row, so they are excluded from both the numerator and
the denominator — the score is not wrong, but nobody could tell "we looked and it
was fine" from "nobody looked".

`004_check_coverage.sql` adds `check_definition.implemented`, and the site page
now prints:

> **Coverage:** 25 checks had eligible URLs on this crawl, out of 55 implemented
> and 134 defined. The remaining 79 are specified in the database but no evaluator
> runs them yet — they are *excluded* from this score, not passed.

Set the flag to 1 in the same commit as the code when you implement one. The gaps
are concentrated in on-page (12), AI content structure (10), performance (8) and
crawlability (7).

## 5. `check_result.penalty` was always 0.00

`CheckRunner` wrote `'penalty' => 0` with the comment *"computed by Scorer"* — and
nothing ever wrote it back. The column read as "nothing is wrong" to anyone
querying the database directly, which is exactly how a report gets built. The
scorer now writes the authoritative value: `weight × sqrt(prevalence) × confidence`.
Verified: T085 at prevalence 0.625 stores 7.9057.

## 6. A stale CSRF token returned HTTP 500

`View::json([...], 419)` — Laravel's convention. Measured under Apache + mod_php:
**a status code Apache does not know is rewritten to 500.** So an expired token
reached the browser as "Internal Server Error", indistinguishable from a crash.
400, 403, 422, 429 and 503 all pass through untouched; 419 does not. Now 403, with
`{"error":"csrf_mismatch"}` as the machine-readable discriminator.

## 7. A staging origin on a non-standard port audited the wrong site

`originFor()` rebuilt the origin from `parse_url()` and dropped the port, so
`http://staging.example:8080` became `https://staging.example` — the audit
silently described a different site than the one requested. The port is kept.

## 8. `migrate-web.php` listed migrations as pending after applying them

`$pending` was computed before the POST handler ran, so the page printed
"Applied 001…005" and then listed all five under **Pending migrations** in the
same response. It reads as "it did nothing", and invites a second run. Recomputed
after migrating.

Also: the confirmation text claimed every statement was `CREATE TABLE IF NOT
EXISTS` or `ON DUPLICATE KEY UPDATE`, which stopped being true when 003 added
`UPDATE`/`DELETE` over this app's own rows. Reworded to what it actually does.

Also: `001_core.sql` creates **34** tables, not the 36 claimed in three places.

---

## Verified end to end, not asserted

A deliberately broken site was served from a second Apache vhost and audited. The
engine found, correctly:

| Code | Sev | What it caught |
|---|---|---|
| T085 | critical | 5 indexable pages served over HTTP |
| A03 | critical | `OAI-SearchBot` disallowed in robots.txt |
| T003 | critical | robots.txt blocking a linked URL (`/private/`) |
| T009 | critical | a sitemap URL returning 404 |
| T001 | critical | `noindex` on an indexable page |
| O031 | high | thin content on 4 pages |
| T026 | high | a broken internal link |
| T109 | medium | 4 pages with no `rel=canonical` |
| T028 | medium | 3 pages with fewer than 3 internal inlinks |
| O009 | medium | missing meta description |
| O003 | medium | a 100-character title at risk of truncation |
| A09 | info | GPTBot blocked — reported, weight 0, not scored |

Score 25, capped by hard gates, with the ceiling reason printed. Categories:
security 20.94, crawlability 79.01, architecture 80.05, onpage 81.29,
ai_access 83.33, duplication 92.55.

Also exercised: the full browser flow from an empty database (all five migrations,
34 tables, 134 checks, 4 sites), the site-domains form (URL paths stripped, bare
hosts accepted, non-domains rejected with a message, blanks skipped), the
self-delete button, CSRF rejection with and without a token, the queue and worker,
and `/api/health`.

Test suites: `env_test.php` 20, `robots_test.php` 33, `scorer_test.php` 17 — all
passing. `scorer_test.php` needs a migrated database and cleans up after itself.

---

## Still not built

Named so nobody discovers them by surprise:

- **No authentication.** The app is protected only by not being linked. Put HTTP
  Basic auth on the folder in cPanel before you put anything sensitive in it.
- **Off-page module** (F001–F030) — schema and checks seeded, no evaluator.
- **Site Analyze** — the country → competitor → keyword → content flow is
  specified and its tables exist; the orchestration is not written.
- **hreflang** (T075–T083) — stubbed, pending an `hreflang_edge` table.
- **79 of 134 checks** — see item 4; `check_definition.implemented` tells you
  which at any time:
  `SELECT code, category, title FROM check_definition WHERE implemented = 0;`
