AstroBaaS

Where it is going

WordPress parity analysis

Generated from docs/WP-PARITY-ROADMAP.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.

The 100 most-installed WordPress plugins, distilled into ~170 capabilities, scored against what AstroBaaS already ships — and a phased plan for the gaps worth closing.

Grounded against the WordPress.org “Popular” ranking as tracked in August 2026 (top tier: Elementor, Yoast SEO, Contact Form 7 at 10M+ installs; WooCommerce 7M+; All-in-One WP Migration, Site Kit, Wordfence 5M+; WP Mail SMTP 4M+ — sources at the bottom). The 20 most-installed plugins account for roughly a third of every active plugin install in the directory, so parity with the head of the curve covers most of what real sites need.

Related documents: ROADMAP.md (25 strategic proposals — this document is the tactical, feature-level view; overlaps are cross-referenced as R-##), PLUGIN_DEVELOPMENT.md, THEME_DEVELOPMENT.md.


The thesis: capability, not plugin

WordPress needs 59,000 plugins because its core is deliberately small and its extension mechanism is arbitrary PHP. AstroBaaS takes the opposite bet: the top-20 plugin capabilities belong in core, type-checked and tested together, and the plugin surface is for genuinely vertical code (an optical shop, a payment provider), not for taping over core gaps. The scoreboard below reflects that: most of what a WordPress site assembles from 15 plugins is already one npm install here.

Three things WordPress does that we deliberately do not copy:

  1. Arbitrary-code plugins from an open marketplace. WP’s #1 security problem is its own plugin directory. AstroBaaS plugins are reviewed TypeScript modules with a capability-scoped context (ctx.store namespaced to the owner, routes confined to /api/plugin/, semver-gated by requiresCore) — never eval, never a “Code Snippets” equivalent (WP #~40, 700k installs, and a top-5 source of white-screen incidents).
  2. In-browser drag-and-drop page building (Elementor, WPBakery, Divi). Our answer is the theme-slot contract plus admin-defined content types: structure lives in typed components, content lives in the CMS. A visual builder that serializes to HTML soup is the single biggest cause of WP lock-in and Core-Web-Vitals failure; the market it serves (non-technical site assembly) is real, but our audience — developers pointing a vibe-coded frontend at a fast backend — is exactly who a builder gets in the way of.
  3. Cache plugins. WP Rocket / LiteSpeed / W3TC exist because WP renders every request through PHP. Astro SSR + our derivative pipeline makes the entire category structurally unnecessary; performance parity is achieved by architecture, not by a feature we must build.

Legend — Status of each capability in AstroBaaS today:

In core, on main, tested
🟡Partial — the foundation exists, the listed gap remains
🧩Ships in the commercial repo (paid module territory by design)
Not built — roadmap item, phase given
🚫Deliberately out of scope (reason given)

The top-100 plugins, grouped

Rather than 100 rows of plugin trivia: the top-100 collapses into 17 capability families. Install counts are WordPress.org buckets (Aug 2026). Every plugin named here is in or near the top-100 by active installs.

#FamilyRepresentative plugins (installs)
1SEOYoast (10M+), Rank Math (3M+), AIOSEO (3M+), Redirection (2M+), Broken Link Checker (700k)
2FormsContact Form 7 (10M+), WPForms (6M+), Ninja Forms (900k), Fluent Forms (600k), Formidable (300k)
3CommerceWooCommerce (7M+) + Stripe/PayPal gateways, CartFlows, Dokan
4Page building & blocksElementor (10M+), WPBakery, Spectra (1M+), Kadence Blocks (400k), Classic Editor (5M+)
5Performance & cachingLiteSpeed Cache (7M+), WP Rocket, W3 Total Cache (1M+), Autoptimize (1M+), WP Fastest Cache (1M+)
6Image/media optimizationSmush (1M+), ShortPixel, EWWW (600k), Regenerate Thumbnails (1M+), Enable Media Replace (1M+), FileBird
7SecurityWordfence (5M+), Solid Security (900k), Sucuri (800k), Limit Login Attempts Reloaded (2M+), WPS Hide Login (1M+), Two Factor
8Anti-spamAkismet (6M+), Antispam Bee, CleanTalk, reCAPTCHA integrations
9Backup & migrationAll-in-One WP Migration (5M+), UpdraftPlus (3M+), Duplicator (1M+), WP Migrate, Better Search Replace (1M+)
10AnalyticsSite Kit by Google (5M+), MonsterInsights (2M+), WP Statistics (500k), Koko Analytics
11Privacy & consentComplianz (800k), CookieYes (1M+), Cookie Notice (700k), WP Consent API
12Email & marketingWP Mail SMTP (4M+), MC4WP: Mailchimp (2M+), MailPoet (600k), Newsletter (300k), OptinMonster (700k), Popup Maker (700k), FluentCRM
13Custom fields & typesACF (2M+), CPT UI (1M+), Pods (100k), Meta Box
14MultilingualWPML, Polylang (700k), TranslatePress (400k), Loco Translate (1M+)
15Users & communityUser Role Editor (700k), Ultimate Member (200k), BuddyPress, bbBress, User Switching (200k)
16Content utilitiesYoast Duplicate Post (4M+), Easy Table of Contents (400k), TablePress (800k), Relevanssi (100k), Advanced Editor Tools (2M+), Shortcodes Ultimate
17Ops & adminWP Crontrol (300k), Query Monitor (200k), Health Check, WP Mail Logging, SeedProd / maintenance mode (1M+), WP All Import/Export (600k), Admin Menu Editor (400k)

Vertical long-tail (each a top-100 member, each a plugin in our model, never core): The Events Calendar (700k), WP Job Manager, LearnPress/Tutor LMS, WP Recipe Maker, Smash Balloon Instagram Feed (1M+), Slider Revolution / Smart Slider 3 (900k), Pretty Links (300k), AMP (300k), Web Stories.


The capability scoreboard

~170 capabilities extracted from the families above. Numbering is continuous so phases can reference rows as C-##.

1. SEO (Yoast / Rank Math / AIOSEO / Redirection)

C-#CapabilityStatusWhere / gap / phase
1Per-page title & meta descriptionPost/page fields, rendered in BaseLayout
2XML sitemaplocale-aware: entries are built at localePath(path, recordLocale(p)), the blog index and the home page are listed per locale where they genuinely differ, and the built-in routes are listed once because they do not. Canonical, hreflang, sitemap and RSS now derive their URL the same way, so they cannot disagree — the smoke suite asserts it on a live three-locale install
3robots.txt managementlib/robots-txt.ts — an editor whose rules are APPENDED to a managed block, so Disallow: /admin and the Sitemap: line cannot be deleted by accident and an Allow: still overrides them (robots.txt matches by specificity, not order). discourage_indexing short-circuits before the custom text is even read. Bare directives join the managed group; a custom User-agent: opens its own — getting that backwards orphans the rule into no group at all, which every crawler ignores while the file looks correct. Readable by decoupled storefronts, which serve robots.txt from their own origin
4Canonical URLsabsolute via public_site_url (PR #30)
5hreflang alternateslib/hreflang.ts
6RSS feedrss.xml.ts
7Open Graph / Twitter cardsBaseLayout meta
8Redirect manager (301/302, per-site data)Legacy URL recovery (PR #29): admin map + regex rules
9404 monitor sorted by business impacttop-404 report weighted by paid clicks — beyond what Redirection offers
10410 Gone for known-dead URLsrecovery pages return honest status codes
11Schema.org structured data (Article, Product, Breadcrumb, Organization)lib/structured-data.ts — Article, WebPage, CollectionPage, Organization, WebSite, BreadcrumbList, Product, from one tested module
12Breadcrumbs8th theme slot; the trail is built once and feeds both the rendered nav and the BreadcrumbList
13Content analysis / readability hints (“green dot”)lib/content-analysis.ts — pure, so the editor imports it and re-runs on every keystroke with no endpoint and nothing to rate-limit. Headings from buildToc, links from extractLinks, images from imagesMissingAlt, text from plainText: no second set of regexes. Deliberately refuses two things Yoast ships — no Flesch score outside English (the syllable model has no validated Greek port) and no passive-voice detection (no Greek word list exists here). Every check that cannot be computed honestly returns unknown rather than guessing, because an author told twice that a correct page is wrong stops reading the panel
14Broken link checkerlib/link-check.ts resolves internal links against the DATABASE — instant, exact, and incapable of a false positive — using the real matchRedirect, so a link a redirect rescues is never listed. lib/link-check-external.ts sweeps outbound links, opt-in and off by default: eight a minute, one per host, behind the same SSRF guard the webhook sender uses, and only 404 and 410 count as broken (bot protection answers 403 to anything without a browser). Reported at /admin/insights
15llms.txt for AI crawlersllms.txt.ts — WP needs a plugin for this; we ship it
16Search-engine visibility kill-switch (noindex site)the toggle already covered meta robots + robots.txt + sitemap; Phase 1 closed the feed and llms.txt, and added per-post noindex

2. Forms (CF7 / WPForms / Fluent Forms)

C-#CapabilityStatusWhere / gap / phase
17Contact form with server-side validationapi/contact.ts, honeypot + rate-limited
18Submission storage & admin inbox (Flamingo)api/messages + admin screen
19Email notification on submissionapi/contact.ts:57 and api/content/[type]/index.ts:244 both call notifySubmission. One helper, both form paths — the first version resolved the recipient from keys no admin screen writes
20Custom form builder (arbitrary fields, drag-drop)writable: 'public' on a content type opens an anonymous POST; /forms/<type> renders the form itself. Read and write are separate deny-by-default axes, so “anyone may send one, nobody may read the others” is the easy pairing and publish-everything is the loud one (PR #42)
21Spam protection on forms (Akismet/CAPTCHA class)honeypot + per-address submission limit + local proof-of-work on every public write, no third-party call and nothing to consent to (PR #42). A pluggable hosted challenge (Turnstile/hCaptcha) stays optional — Phase 4
22Multi-step forms, conditional logicshowIf: {field, equals} on a field and steps: [{title}] on the type — ONE mechanism with C-125, which two plans were about to build twice under two names. The half that matters is the SERVER half: schemaForSubmission makes a hidden required field optional (without it the form an operator built is unsubmittable and answers “x is required” about a box nobody was shown) and DROPS a hidden field’s submitted value, because a browser is not the only thing that can POST. A condition may only name an EARLIER field, which makes a cycle impossible by construction. GET /api/forms/<type> serves the schema publicly for a headless storefront — the form builder did not exist for either live install before it
23File-upload fields on public formslib/media/private-files.ts. The feature is WHERE THE BYTES GO: public/uploads is served by the static handler in dev and a reverse proxy in production, so a file written there is readable at its URL whatever the record’s read policy says — a type marked visibility: 'staff' collecting CVs or prescriptions would have been publishing them. Submission files go somewhere no static handler is told about, content-addressed so the id cannot be guessed, readable only through a staff-only route that serves them as an attachment with nosniff and no-store. The upload answer carries an id and NEVER a url. A distinct file field kind rather than reusing media, because merging them means one read policy silently applies to the other in the direction that publishes a CV. GDPR erasure deletes the bytes BEFORE the record
24Payment forms (WPForms Stripe)🧩commerce checkout already does this; generic “pay-what-you-want form” stays commercial

3. Commerce (WooCommerce)

Everything here exists in core today (products, brands, categories, coupons, shipping methods, orders, customers, payments, checkout) — the strategic decision recorded in GO-PUBLIC.md is whether it stays GPL in core (the Woo move) or extracts to the commercial suite before the repo goes public. This table therefore marks present-tense location, not aspiration.

C-#CapabilityStatusWhere / gap / phase
25Product catalogue with variantsapi/products, lib/product-fields.ts
26Product categories & brandsapi/product-categories, api/brands
27Cart & checkoutlib/commerce
28Orders & order managementapi/orders + admin
29Customersapi/customers
30Coupons / discountsapi/coupons
31Shipping methods & zonesapi/shipping-methods
32Payment gatewayslib/payments abstraction with Stripe BUILT IN (lib/payments/stripe.ts — hosted Checkout with an idempotency key, refund through the PaymentIntent behind the session, webhook verification). The registry refuses to let a plugin shadow a built-in id, so stripe cannot be silently replaced. IRIS PSP lives in the commercial repo awaiting merchant docs. NOT sandbox-verified — see the README
33Stock / inventory trackingproduct fields
34Sales notifications & order emailsstaff notification AND a customer order confirmation, with the bank details it promises. MANUAL_INSTRUCTION_KEYS gives the built-in bank-transfer and cod methods a settings-backed writer, and the text reaches BOTH the email and GET /api/payments — the checkout’s own source
35Product reviewsReviews as a MODERATED content type, not a bespoke table — the row proposed its own storage on three drivers, its own migration, its own GDPR registration and its own screen, and once per-record approval existed the remaining difference from any other collection was the fields. verified_buyer is deliberately NOT a declared field, which is what makes the server stamp safe: validate() copies nothing it was not asked for, so a posted verified_buyer: true is gone before any route logic runs (smoke proves it), and only a PAID order counts because an abandoned basket is the cheapest way to fake one. aggregateRating is emitted only when there is something real to say — it asks Google to show STARS against the shop’s name, and a node with reviewCount: 0 is a structured-data error that invites a manual action. Off by default
36Subscriptions / recurring billing🧩commercial territory, needs a PSP with tokenization first
37Multi-vendor marketplace (Dokan)🚫wrong product; would distort the permission model
38Sales funnels / checkout flows (CartFlows)🧩commercial, after Stripe
39Invoicing / PDF receiptsInvoicing / PDF receiptsshipped, the non-fiscal half. A printable receipt at /receipt?token=…, reached from the confirmation email. It says plainly that it is not a tax invoice, in all three languages, because in Greece a retail invoice is issued through myDATA and carries a MARK — a PDF that was never transmitted is a liability with a logo on it. No server-side PDF, for C-152’s reason unchanged. The token is per-order, signed, two years, and REFUSED for an erased order however valid it is; a bad token, an unknown order and an erased one all return the identical 404, so the page is not an order-number oracle. myDATA, legally-numbered sequences and credit notes stay commercial

4. Page building, blocks & editing

C-#CapabilityStatusWhere / gap / phase
40Rich text editing with media embeddingthe picker had dispatched mediaSelected since it was written and the only listeners set the FEATURED IMAGE — browsable, and unusable for the thing people open it for. lib/media-embed.ts builds the markup (self-closed <img> inside a <figure>, because that is what sanitize-html normalises to — a test asserts the round trip is byte-identical) and lib/editor-insert.ts performs the insert. That module also replaced the TWO copies of the insert glue inside SectionInserter.astro, so the caret rule and the hidden-textarea sync now have one implementation and three callers
41Reusable structured sections on pagesreorder plus configure, now for PLUGIN sections too, with the variant class namespaced to the plugin so a manifest cannot mint a core class (PR #42). This is the “blocks” answer: typed sections, not HTML soup
42Full drag-and-drop page builder🚫see thesis §2
43Shortcodes🚫string-splicing code into content is the WP mistake we refuse; sections (C-41) are the typed replacement
44Embeds (YouTube, maps, socials) with privacy facadeEmbeds with a privacy facadeshipped. The old note’s premise was false and that shaped the design: iframe is not in the sanitizer’s allow-list and never will be, so nothing stores a frame. A placeholder carries a provider id and a video id, the sanitizer validates the id against THAT provider’s shape, and the URL is BUILT from the pair at render and again in the browser — the most an attacker with write access to post HTML can express is a different YouTube video. The facade makes no third-party request before a click, not even a thumbnail (a YouTube poster is served by Google, which is the whole thing a facade prevents). YouTube on youtube-nocookie, Vimeo with dnt=1, OpenStreetMap. Instagram/X/Facebook/TikTok are refused with a sentence saying why
45Tables in content (TablePress)Tables already survived the sanitizer and lost five things silently: <caption> was DISCARDED and its text kept, leaving a bare text node inside <table> that every browser foster-parents OUT — so the caption reappeared above the table as loose text — and colgroup, col, tfoot, colspan, rowspan, scope and headers were all stripped. Fixed on td/th specifically, never on '*': widening the wildcard is how an allow-list rots. An absurd span is CLAMPED to 100 rather than dropped — a pasted colspan="100000" is a layout denial-of-service, and dropping it would silently unmerge a real table. Plus a table section whose template ships a caption, a thead and scope
46Table of contents auto-generationlib/toc.ts — bare-slug anchors, deduplicated document-wide, author ids never renamed. A TableOfContents theme slot; toc_min_headings in Reading settings, 0 = off (the default). Anchors are added unconditionally and reach content_rendered, so a headless storefront links to the same ids the sitemap does. The body an API route returns is the body the SSR page renders, byte for byte — same function (C-58). The rendered contents list stays a theme slot rather than an API field
47Duplicate post/page (Yoast Duplicate Post, 4M installs)POST /api/posts/{ref}/duplicate — always a draft, own slug, no history
48Revisions & undo historylib/revisions.ts
49Scheduled publishinglib/scheduler.ts
50Drafts & previewpost status + preview
51Content templates (“start from”)Two Reading settings prefill a new record from a named pattern. The pattern NAME is stored, never its HTML: a stored blob is never re-vetted, so it rots the day the section vocabulary changes — which is the failure the pattern registry’s vet exists to prevent. resolvePatterns is the same call the palette makes, so a pattern the vet rejected is offered by neither. Switching Type swaps the layout only while the body is still untouched

5–6. Performance, caching & media

C-#CapabilityStatusWhere / gap / phase
52Page cachingthe category is structurally unnecessary (thesis §3): Astro SSR + CDN-friendly headers do the job a caching plugin exists to do
53Asset minification/bundlingAstro build pipeline
54Image compression on uploadderivative pipeline (PR #30), measured ~2.1MB→~340KB per image set
55Responsive derivatives (srcset sizes)fixed derivative set at upload, originals untouched
56WebP/AVIF conversionWebP today; AVIF — Phase 3, encoder cost vs. gain to measure
57Lazy loadingcards lazy-load; the article hero is deliberately eager + fetchpriority=high because it is the LCP element, and lazy-loading the LCP delays the measurement it appears to help. Content images get the hint on render, so existing posts benefit with no migration
58Width/height attributes (CLS)lib/image-dimensions.ts — body images carry the file’s own dimensions, matched through absolute URLs, cache-busting queries and derivative variants. Theme images are deliberately excluded: object-cover sizes them from CSS, so attributes there set a ratio CSS overrides. Reaches content_rendered on both API routes (one media read per response, not per post). All four renderers — two SSR, two API — now run ONE pipeline, lib/content-render.ts: filters, sanitizer, lazy hints, anchors, media, in that order. The API pair used to skip the sanitizer and the hints while promising the opposite, which for a headless install meant plugin markup reached the storefront untouched
59Regenerate thumbnails (rebuild derivatives)🚫dropped, with reasons on record. A replacement (C-60) already regenerates the full set, and rebuilding without a new file only matters after a change to the width list — which is a deploy, not a button
60Replace media in place (Enable Media Replace, 1M)the record keeps its id and every stored reference is rewritten to the new file; old files are removed unless another record shares them (PR #42)
61Media library folders/collections (FileBird)lib/media/folders.ts — LABELS, not directories: files stay content-addressed and never move, because a real rename would rewrite a URL already embedded in published posts, in a cached feed and in whatever a headless storefront rendered last week. folder was a WRITE-ONLY PHANTOM — stored by the patch route since it was written, absent from MediaFile so no typed reader could see it, and no filter existed. Writer and filter now share one normaliser; ?folder= distinguishes the unfiled bucket (empty value) from no filter at all by whether the parameter is present
62Alt-text management & auditlib/image-dimensions.tsapplyImageAltText beside the width/height applier, sharing its regexes and its “never overwrite the author” rule: an explicit alt="" marks a decorative image and is never filled. PATCH /api/media/update is the writer the field never had (refused on a bulk patch — alt describes one picture). One media read returns dimensions AND alt together. The audit counts undescribed images on the STORED content, so it shows what an author must fix rather than what the pipeline papers over
63Image galleriesImage galleriesshipped. A real gallery section with its own inserter entry, not a paragraph of images
64Video hosting guidance / poster framesA video section that is a poster frame LINKING OUT, and the guidance that goes with it. This CMS hosts no video — the ingester refuses one by magic-byte sniff — and embeds no third-party player, because iframe is absent from the sanitizer’s allow-list and that is what keeps the CSP intact. So the honest control says where the video lives rather than pretending to host it
65CDN base URL for mediamedia-base.ts + public_site_url separation
66Database optimization (WP-Optimize)🚫no autoload table to bloat; libSQL/relational drivers don’t need it

7–8. Security & anti-spam (Wordfence / Solid / Akismet)

C-#CapabilityStatusWhere / gap / phase
67Login rate limitingLOGIN_LIMIT 10/15min — do not relax (standing rule)
68Two-factor authenticationapi/2fa
69Password-reset hardeningdedicated limiter, no user enumeration
70Session managementserver-side sessions, revocation
71CSRF protectiondouble-submit cookie + header, all mutating routes
72Security headers / CSPcsp-config.ts, hash-based CSP (why consent.js is a single file)
73Audit log (who did what)lib/audit.ts — WP needs a paid plugin for this
74Rate limiting on public APIslib/rate-limit.ts, measured limits in reference nginx
75Input validation everywhereFieldRule + settings-validate; “no unexpected types or symbols” (standing rule)
76Malware scanning of uploadsMalware scanning of uploadsshipped. clamd over TCP or a UNIX socket, off by default because it needs a daemon. Two decisions carry it: it FAILS CLOSED (only the exact word open opens it, so a typo cannot silently disable the refusal), and it runs at BOTH upload doors including a stranger’s file on a public form — which matters more than the admin upload, not less. Scanned before any re-encode, because re-encoding can destroy a signature while leaving a polyglot’s other half intact
77Web Application Firewall🚫belongs in nginx/Cloudflare, documented in reference deploy, not in app code
78Hide login URL (WPS Hide Login)🚫security theater; throttle + 2FA are the real controls
79Comment/form spam scoring (Akismet class)lib/spam-score.ts, local. No third-party service: Akismet works by sending every submission — text, name, email, IP — to someone else, which on a self-hosted CMS is a disclosure the operator would have to make in their own privacy notice. No phrase dictionary either, because a list of “spam words” is a list in ONE language and both live installs write Greek. Every signal is structural: link count, link markup in a plain-text field, a name that is a URL, shouting, character runs. Nothing is REJECTED on the score — a flagged submission is stored and shown, because rejecting on a heuristic means the one enquiry that mattered is the one that vanished while its sender was told it went through
80API keys with scopesapi/keys, api-key-scopes.ts — headless story WP doesn’t have
81Brute-force IP intelligence sharing🚫phones home; against self-hosted ethos
82File integrity monitoringFile integrity monitoringshipped. A sha256 manifest at deploy plus npm run integrity:verify, exiting non-zero. Changed / added / removed are reported separately because an ADDED file is what a web shell is. The manifest sits on the same disk as the files it describes, which is stated in the module and in the command’s own output rather than glossed — the digest it prints, recorded off-box, is what makes it real

9. Backup & migration (UpdraftPlus / Duplicator)

C-#CapabilityStatusWhere / gap / phase
83On-demand full backupapi/backup + admin
84Scheduled backupsscheduler
85Restore from backuptested restore path, on all three drivers, exercised end-to-end in smoke. Restores v1 AND the v2 off-site shape, and no longer drops SVG — the extension allow-list omitted it while ingest accepts it, so a restore used to delete every logo from disk
86Off-site backup targets (S3/Drive)any S3-compatible bucket on the existing scheduler timer, SigV4 signed by hand and pinned against AWS’s published worked example. Archives what the ACTIVE driver stores, and refuses a remote Turso install rather than uploading a stale db.json (PR #42)
87Full-site clone/migrate (All-in-One, 5M)The download route was a SECOND implementation: it re-spelled the directory walk and the mime guess backup/offsite.ts already had, read db.json directly, and hard-refused whenever DATABASE_URL was set — so on the drivers a real deployment runs, the Download button did nothing but explain itself, while the RESTORE route already understood the libsql-file archive the off-site backup was writing on those very installs. One half of the pair could read a format the other half could not write. Now one builder, one format: a file-backed libSQL install downloads and restores its own archive, proved end to end on the libSQL smoke driver. Plus POST /api/backup/run (the SAME function the scheduler calls, so a manual success cannot mask a scheduled failure) and astrobaas clone <src> <dst>, which names the TARGET first and refuses without --yes. NEVER a consistent snapshot: the SQLite path reads a live file and copies the write-ahead log beside it
88URL search-replace on migrateunnecessary by design: absolute URLs derive from public_site_url, one setting change re-homes the site (the 1M-install Better Search Replace plugin exists because WP bakes URLs into content)
89Staging environment guidancedocs/STAGING.md and code, because the three switches that make a clone safe live in the DATABASE and therefore arrive with the clone. STAGING=1 forces noindex, silences fireEvent and returns no analytics providers — one-way, so nothing in the environment can force a site to be indexed. The sweep mattered more than the flag: FOUR of the five discourage_indexing readers spelled !!setting inline, so an override added to the helper alone would have hidden the pages while still submitting the sitemap, the feed and llms.txt
90Import from WordPress (WXR)posts, pages, media, categories, tags, dates and a generated 301 map into the legacy-recovery engine. CLI, POST /api/import/wordpress and /admin/import, rehearsing by default and safe to re-run. WooCommerce shops via npm run import:woo. Authors are REPORTED, never invited (PR #42)
91Import/export own content (JSON/CSV)Import/export your own contentshipped. ONE route for post, page and every content type, because two would mean two answers to “what does a boolean look like in a spreadsheet”. Previews by default, computed by the identical function that then applies it. Rows match on slug or id, never on position, so a file the operator sorted still imports correctly. The CSV defuses formula cells (=HYPERLINK(…) in a title exfiltrates the row when the operator opens their own export) and carries a BOM, without which Excel turns every Greek title to mojibake
C-#CapabilityStatusWhere / gap / phase
92GA4 integrationANALYTICS_PROVIDERS, consent-gated
93Meta Pixelconsent-gated, verified live in-browser
94TikTok Pixelconsent-gated, verified live in-browser
95Privacy-first analytics (Plausible/Umami class)providers registered, consent-aware
96Server-side page-view counting (Koko class)lib/views.ts counts and flushes on the scheduler tick. The API path gates on isReaderRequest, which counts anonymous AND api-key callers — the !user version made both live HEADLESS shops count zero. bumpPostViews touches only the counter: no updated_at (which the sitemap publishes as lastmod) and no change-feed entry (which is capped and was evicting editorial history)
97Consent banner (accept/reject equal weight)banner plugin, hash-CSP compatible
98Consent categories & granular choicenecessary/preferences/analytics/marketing
99Consent versioning & re-promptCONSENT_VERSION, 6-month lifetime
100Multilingual consent UIPR #36 — en/el/de, tested every-locale-complete
101Script blocking until consentsingle gated loader; zero vendor bytes pre-consent (verified)
102Consent audit trail (proof of consent)a receipt proving a decision happened while holding nothing about who made it: categories, text version, time, opaque browser-generated id. Written by beacon, readable only by an admin (PR #42)
103Cookie scanner / declaration tablelib/cookie-declaration.ts — generated from configuration, never from a scan. A scan sees one page load with one consent state on the day it ran; this knows the answer without looking, so a vendor switched off leaves the table on the next request. First-party lifetimes are DERIVED from SESSION_TTL_MS and CONSENT_MAX_AGE_DAYS so the declaration cannot become a false retention claim. Rendered at /cookies, in /admin/privacy, and served to decoupled storefronts by GET /api/consent/cookies — the caveats travel with the data
104Google Consent Mode v2 signalsall seven signals including the two v2 ones, mapped once and shipped to the browser as data — without loading anything from Google before consent (PR #42)
105Data-subject requests (export/erase user data)/admin/privacy, admin only, both directions audited. Export is an explicit allow-list; erasure anonymises orders instead of deleting them, because Article 17(3)(b) says a shop must still produce its accounts (PR #42)
106Legal pages managementlib/legal, api/legal
107In-dashboard analytics report (Site Kit class)/admin/insights. Traffic is totals and rankings and SAYS SO: post.views is a running counter with no record of when a view happened, and every way of drawing a time axis from it invents a history. Outcomes — paid orders, submissions, signups — carry per-event timestamps, so those are real 30-day series with capped stores labelled. Charts are inline SVG because <rect width> is a presentation attribute the CSP does not strip, unlike the style="width:…" a styled div would need

12. Email & marketing (WP Mail SMTP / MailPoet / Mailchimp)

C-#CapabilityStatusWhere / gap / phase
108Reliable SMTP deliveryhand-written SMTP client (lib/email-smtp.ts), selected by EMAIL_TRANSPORT=smtp. The Conversation class owns the live socket, so DATA lands on the post-STARTTLS one — the earlier version wrote it to the plaintext socket and failed on port 587, the default. SNI omitted for IP literals; self-signed certs and plaintext AUTH both off unless explicitly enabled
109Email log / delivery visibility/admin/operations — who, what, how and whether it worked, and never the body. The log holds addresses, so the GDPR tooling covers it (PR #42)
110Newsletter signup + double opt-indouble opt-in storing nothing before confirmation, and unsubscribe with a SEPARATE token purpose so a confirmation link cannot be replayed to remove someone. The confirmation page hands the reader a leave link immediately. Both signup forms say “check your email”, not “subscribed”. A List-Unsubscribe header belongs to C-111/C-112, which is where outgoing campaigns live
111Newsletter sending / campaignslib/newsletter-campaign.ts plus a composer on Messages. The load-bearing part is List-Unsubscribe and its one-click companion — the header that decides whether a mailing list is treated as a mailing list or as a person sending a lot of mail, and a message without one is routed toward the spam-complaint button, which costs the SENDING domain (the same one the order confirmations leave from). EmailMessage gained a headers field threaded through the MIME builder, where names and values are sanitised in ONE place; the message identity headers cannot be overridden. Sending is a STATE CHANGE, not a loop: the existing scheduler tick sends a batch at a time from a cursor, so a restart resumes rather than sending the newsletter twice. It is NOT an ESP — no bounces, no complaint loop, no suppression list — and the screen says so where an operator will read it
112Transactional email templatesTransactional email templatesshipped. Five editable, admin only. The safety is one idea: each declares which placeholders it cannot do without, and a body missing one is refused with the reason — a password reset without its link sends, looks fine, and is useless. A stored template that fails validation falls back to the built-in wording rather than sending something broken. The order confirmation is subject-only: its body is an itemised document with two renderings that must agree
113ESP sync (Mailchimp/Brevo class)ESP syncshipped, as the primitive rather than a Mailchimp adapter. subscriber.confirmed (first double-opt-in click only) and subscriber.unsubscribed are now webhook events; there was no subscriber event of any kind before, so the old note’s “webhooks cover 80%” covered none of it. Deliberately NO event for an unconfirmed signup — an ESP receiving that is importing an address nobody consented with. The unsubscribe is the one that must fire: one this site honours and the ESP does not ends in a spam complaint
114Popups / opt-in overlays (OptinMonster, Popup Maker)Popups / opt-in overlaysshipped as a bundled plugin, so an install that never asked carries nothing: deactivating it 404s /popup.js and no page links it. Frequency-capped with a floor of one day, remembers a signup so nobody is asked twice, never covers the page, closes on Escape, and waits for a consent decision so it cannot stack on the cookie banner. Exit intent falls back to the delay on a touch screen
115Automated flows (abandoned cart)🧩commercial, needs C-32 first

13. Custom fields & types (ACF / CPT UI / Pods) — the reason this document exists

C-#CapabilityStatusWhere / gap / phase
116Admin-defined content types (CPT UI class)PR #37 — /admin/content-types, browser-defined, live without restart
117Typed field rules with validation (ACF class)12 field kinds, server-enforced and failing closed (ACF validates client-side only for most types). ref and media landed in Phase 2; the same pass found slug, email, url and date had been accepted and silently discarded since the builder shipped
118REST exposure per type/api/content/<type> immediately — ACF needs “show in REST” + extra plugin
119Read-access policy per typedeny-by-default staff / explicit public — and since Phase 2 an independent WRITE axis, so read and write cannot be conflated
120Generated entries admin UI/admin/content/[type] from schema — CPT UI doesn’t do this at all
121Plugin-declared types (code-first, ACF PHP registration)registerContentType() + manifest types; name precedence over admin types
122Relation fields (post-to-post, ACF Relationship)ref names its target collection and refuses a dangling link on create AND on update; a ref whose target type was deleted is refused rather than validated against rows nothing can reach (PR #42)
123Repeater / nested groups (ACF Pro flagship){type:'repeater', fields} — one rule, ONE nesting level, refused deeper by both definition doors. The walker was written BEFORE the rule: three places filtered a definition’s fields flat, and one of them is the GDPR sweep that FINDS a data subject, so an email nested in a repeater item would have answered a subject-access request with “we hold nothing about you” while holding it. lib/field-walk.ts walks nested fields and a guard fails any file that goes back to a flat filter. A hard server ceiling of 200 items whatever the definition asks, because the only other bound was the 2 MB body cap
124Image/media field kindstores the media id, resolves a sibling <field>_url on read so a moved domain or a replaced file cannot strand a baked-in URL (PR #42)
125Conditional field displayThe same mechanism as C-22, not a second one — the recon found two plans for one showIf shape under two names, which is exactly how the client and the server come to disagree about whether a field was required. The admin entry screen applies the identical rule the public form does, and the server re-evaluates it on the way in
126Flexible content layouts (ACF Pro){type:'repeater', layouts} — each item picks a NAMED shape and carries a _layout tag. ACF’s flexible content as one rule rather than a second feature. Deliberately confined to custom content types: Post.content stays a sanitized HTML string, because a block TREE was refused on record, and a headless storefront reads a flexible list as ordered JSON with a layout tag per item
127Fields on settings screens (ACF options pages)core/setting-groups.ts, reusing the content-type field vocabulary rather than inventing a second one — a parallel list is the sibling gap that kept ref and media out of the manifest door for months. The generated form is not the feature; the ENFORCEMENT is: without a server-side rule, POST /api/settings/update accepts any shape under 64 KB, so a field declared as a number stores the word “later”. The storage key IS the read policy — public_group.<id>.<field> is disclosed by the existing isPublicSetting, so a headless storefront reads a declared group with no new endpoint, and a group is PRIVATE unless its author says otherwise
128Custom taxonomiesCustom taxonomiesshipped, and additive: a definition in settings, terms as ordinary records, one optional field on the record. No migration, and an install that defines none is byte-identical to before. Removing a taxonomy or a term rewrites nothing — the assignment stops being read and returns if it is redefined. Term slugs allow Greek and Cyrillic; the first draft used one ASCII rule for both and would have silently dropped every Greek term on both live installs

14. Multilingual (WPML / Polylang / TranslatePress)

C-#CapabilityStatusWhere / gap / phase
129Multi-locale content with per-locale URLs/el/, /de/ routing, lib/i18n. The prefix is now reachable BY CLICKING: lib/locale-links.ts localises every visible link on the public site — including the stock welcome page, the last holdout and the page a FRESH INSTALL serves — and a crawlable language switcher renders in all three headers, marking which entries are real translations and which fall back to a home page. /admin and the auth routes stay unprefixed because they are not localized routes. Before this, i18n stopped at the <head> — the nav, footer and cards carried hardcoded hrefs, so the first link a German reader clicked dropped them back into English
130Translated admin UIen/el/de locale packs
131Post/page translation linkingtranslation groups + hreflang
132Locale-aware sitemap/RSSboth go through localePath + recordLocale. See C-2
133Translation status dashboard (“what’s untranslated”)/admin/translations, across both translation models, open to everyone who writes content. Flags the half-translated products that every locale-key check reports as done (PR #42)
134Machine-translation assistMachine-translation assistshipped as one of six editorial AI tasks. It NEVER writes: the draft lands in a read-only box and a person presses apply, which is the only thing that makes offering it safe. {{target language}} is required and validated against the locales this install actually runs
135RTL supportRTL supportshipped. The note called it a theme-token concern; the theme tokens are FLEX directions and there was no dir attribute in the codebase at all, so an Arabic install rendered left-to-right and nothing could change it. dir now derives from the reader’s locale, and scripts/gen-rtl.mjs --check fails the gate on any physical utility the stylesheet does not flip — it found six missing the first time it ran
136Per-locale settings (site title, tagline)Per-locale settingsshipped. The per-locale pattern already existed three times and had simply never been pointed at the settings table. A whitelist decides what may be localised, so nobody can create a locale-keyed API key, and a half-filled block overlays only what it filled. The admin post list deliberately does NOT localise its byline

15. Users & community

C-#CapabilityStatusWhere / gap / phase
137Role-based access controladmin/editor/staff, admin-access.ts route rules
138Custom roles / per-capability editing (User Role Editor)PER-CAPABILITY EDITING of the five built-in roles, which is what the plugin this row cites is actually used for — and explicitly NOT arbitrary role names: a custom role is not a row in a table here, it is a value that has to satisfy dozens of role === 'admin' comparisons through the routes, every one of which silently answers “no” for a name it has never heard, producing a role that can sign in and do nothing. lib/capabilities.ts was extracted with the grants copied verbatim and PINNED by a test against the hard-coded predicate bodies before any override existed, so a permissions change could never arrive mixed with a change in how permissions are computed. ADMIN IS NOT REDUCIBLE (a locked-out admin has no CLI verb to repair it), an unset capability falls back rather than denying (or every capability added later would arrive switched off on upgrade), and the predicates stay synchronous — the middleware hands them the overrides once per request, and a failed read degrades to the SHIPPED policy
139User profiles with avatarsusers API + admin
140Front-end registration/login (Ultimate Member)🧩customers exist for commerce; general membership — commercial or plugin
141User switching (support/debug)User switchingshipped. Admin only, never onto another admin, refused while already switched, one hour, audited at both ends. Switching back re-checks the admin’s status, role and session version rather than trusting the cookie. The banner is rendered by AdminLayout on every screen from the signed cookie, not a prop. What is not claimed: actions taken while switched are audited as the impersonated user, and the two bracket entries are what attributes the window
142Comments on postsA MODERATED content type, off by default, with a rendered thread and form on the article page. The row was blocked on a primitive rather than on a feature: visibility is collection-wide, so public published every row including one posted thirty seconds ago by a bot. core/moderation.ts fixed that for every collection at once — a public submission lands pending and 404s on its own URL as well as being absent from the list, staff see every state including rejected, and _status is refused as a declared field name so a submitter cannot post their own approval. Switching it on reloads the registry, because otherwise the operator sees “Settings saved” and the endpoint 404s until a restart
143Forums (bbPress) / social network (BuddyPress)🚫verticals; third-party plugin territory
144Content locking / paywall🧩commercial

16. Content utilities

C-#CapabilityStatusWhere / gap / phase
145Site search (public)api/search.ts + lib/text-search.ts
146Weighted/fuzzy relevance (Relevanssi/SearchWP)field weighting, phrase bonus and AND semantics were already built; what was missing was any core implementation behind the TermExpander seam — SEARCH_EXPAND had exactly ONE consumer, product search, so a shop’s synonyms worked in the catalogue and not in the blog. lib/search/expander.ts fills it with the operator’s own synonym table, bidirectional by default (a, b, c) with an explicit => for the cases where direction is meant. Typo tolerance and Greeklish stay in the paid module because both need an index of the shop’s own vocabulary — a correction against a dictionary suggests words the shop does not stock
147Related postslib/related.ts is now RENDERED: blog/[slug].astro reads related_posts_count (0 = off, with a control in Reading settings) and renders the strip through the theme’s PostCard slot. Excludes translations, other locales, pages and noindex records
148Reading timein post-cards.ts (PR #35)
149Post ordering controlcomparePostsForListing in core/post-query.ts — pinned, then menu_order ascending with absent LAST, then date, then id for a total order. Both fields are absent on every existing row, so the ordering is byte-identical until an editor pins something, which is why it is the default rather than an opt-in ?sort= a headless storefront would never send. The relational driver COALESCEs both keys: json_extract is NULL on every pre-existing row and a bare ORDER BY sorts them all wrong on that driver alone
150Editorial workflow (draft→review→publish)Editorial workflowshipped. A review gate an author cannot publish past, off unless the operator turns it on
151Broken-media reportlib/media-check.ts, beside the broken-link report and resolved the same way — against the DATABASE, never fetched. Reports images in published content that no media record answers for, and names a missing FEATURED image separately, because that one is visible in four places the author never opens: the archive, the card, the Open Graph tag and the feed. Scoped to /uploads/: a theme asset in public/ is not in the library and is not missing, and a report with entries in it that are fine is one an author learns to close. The other direction — files no published post links to — is offered as “nothing links to these”, never as “safe to delete”, because this reads post content and cannot see a settings value or a hardcoded theme path. At /admin/insights
152Print styles / PDF export of articlesPrint styles / PDF exportshipped. The stylesheet half shipped earlier and nothing told a reader it existed. The button is rendered by the ROUTE, not by the article component — three PostArticle implementations exist and asking each to remember it is how one ends up without it. The browser’s own dialogue, not a server PDF: that means a ~300 MB headless Chromium on every self-host for a file every browser already makes from this stylesheet
153Archive pages (author/date/category)Archive pagesshipped. Author and date archives, the author one opt-in twice (a site setting AND that person’s own flag) because these “authors” are shop staff, not bylined journalists

17. Ops & admin

C-#CapabilityStatusWhere / gap / phase
154Health check endpoint (deep, non-200 on failure)PR #30 — probes image encode, uploads writable, plugins requested-vs-loaded, DB write, limiter store
155Scheduled-job visibility (WP Crontrol)/admin/operations says whether the scheduler is running in THIS process, not merely enabled, and surfaces a failing sweep instead of showing healthy (PR #42)
156Maintenance modelib/maintenance.ts
157Query/performance monitorQuery/performance monitorshipped. Named spans, a bounded ring buffer, per-path totals (ninety 200 ms requests is the thing to fix and it is invisible in a list sorted by duration), a Server-Timing header and a section on /admin/operations. Off by default: keeping a list of recent URLs is a decision. The AsyncLocalStorage limitation is written down where the fix would start
158Admin menu customization🚫menu is designed, grouped, i18n’d (PR #33); customization reintroduces the chaos it solves
159White-label adminThe wordmark and a logo, on the sidebar and all three auth screens — the sign-in page is the first thing a white-label operator’s staff sees, so leaving it hardcoded made the setting look half-applied. A third-party logo URL is refused: an image pulled from another server is a request the operator never made, on a screen behind their staff’s login. The accent already shipped. The legal notices STAY — GPL-3.0 §7(b) permits requiring them — and the help text says so rather than leaving it to be found in a licence file
160Webhooks out (site events → URLs)api/webhooks + admin, HMAC-signed
161REST API with OpenAPI contractopenapi.json.ts, 145 contract checks in CI — WP has no equivalent
162CLI (WP-CLI class)The row’s headline was “add verbs” and that was the least valuable third. THE SHIPPED CLI WAS BROKEN: scripts/scaffold.mjs was missing from package.json’s files, so plugin new and theme new — the two commands a new user runs first — died from an installed package with a module-not-found trace, and every test ran from the checkout where the file exists. tests/cli.test.mjs now packs the tarball and runs the commands against THAT. Two offline tools also wrote path.resolve(cwd, 'db.json'), ignoring DB_PATH (so a container wrote next to the code while the server read the volume) and DATABASE_URL entirely (so npm run setup on libSQL created an account in a file nothing opens, printed success, and the operator then could not sign in). They honour DB_PATH and REFUSE on a SQL driver. Verbs: `content types
163AI writing assistantAI writing assistantshipped. Six tasks in one module because they are one operation. Authenticated and gated on author_posts, unlike the public chat route which is merely bounded — every call spends the operator’s own credit. The answer is sanitized server-side and never applied without a person
164Reference deployment (nginx+systemd, measured limits)PR #30, real numbers from production incidents
165Update/migration safetylib/migrations.ts, versioned
166Multisite / multi-tenant🚫one instance per site; containers are today’s multisite
167Theme switching, whole-site (the WordPress promise)PR #35 — slots incl. Home & PageArticle, tokens, 3 themes prove it; Phase 1 added Breadcrumbs as the 8th
168Theme marketplace/distributionTheme distributionshipped, as the install mechanism: npm i, name it in ASTROBAAS_THEMES. The index itself is curated by the owner. Three refusals, each for a failure that would otherwise be silent: a package cannot claim a built-in id, two packages cannot claim one id, and an incompatible requiresCore is refused at the door
169Declarative (no-code) themesmanifest themes: tokens + data, no components needed
170Child themes / per-site overridesChild themesshipped. Components merge per slot and an omitted or explicitly-undefined slot INHERITS rather than erases; tokens merge per LEAF, because ThemeConfig is nested and a shallow merge lets one colour blank a whole palette; CSS is parent-first so a child wins by cascade without !important. A missing parent or a cycle renders the child alone with a loud log and a badge on the themes screen — never a blank page
171Newsletter studio (campaign composer, segments, A/B subject, scheduling)🧩Owner decision, Sept 2026. Core keeps what a shop NEEDS: double opt-in, an unsubscribe that works, List-Unsubscribe, batched plain-text sends (C-111). The paid module is the marketing surface on top — a visual composer, saved segments, subject-line A/B, send-time scheduling and per-campaign reporting. The split is deliberate: nobody should have to buy a plugin to email their customers lawfully, and nobody needs a composer to do it
172Landing pages + UTM tracking🧩Owner decision, Sept 2026. Standalone campaign pages that bypass the site chrome, with UTM capture that survives to the order. Paid because it is pure marketing surface — a shop sells without it. Must obey the existing consent gate: UTM parameters attached to a person before consent are analytics, not necessity, and the core’s posture on that does not bend for a paid module
173Affiliate marketing🧩Owner decision, Sept 2026. Referral links, attribution windows, commission calculation and payout reporting. Commercial by nature, and it touches money — it belongs beside the commerce modules rather than in a GPL core every install carries
174Analytics heatmaps, self-hosted🧩Owner decision, Sept 2026, with a hard constraint: it runs on the operator’s OWN infrastructure. No Clarity, no Hotjar, no third-party recorder — those ship a session recording of the shop’s customers to somebody else, which contradicts everything the consent and privacy work in this core is for. Feasible as first-party capture (pointer and scroll samples, aggregated server-side, no replay of form fields) behind the existing consent gate. If it cannot be built without a third-party service, it does not ship

Score: 153 ✅ shipped · 0 🟡 partial · 0 ⬜ roadmap · 10 🧩 commercial · 11 🚫 refused. That is 153 of 174 (88%), and every one of the 153 rows that are actually in scope once the refused and the deliberately-commercial are set aside. There are no open rows left.

No in-scope row is open. C-39 was the last one, and it was waiting on an owner decision rather than on work; that decision was taken — ship the non-fiscal receipt in core, keep anything claiming fiscal status commercial — and the receipt is built. Every in-scope row is shipped, tested against all three storage drivers, and documented.

Counted by scripts/docs/regen-parity-artifact.py from the rows themselves, which is also what rebuilds the published artifact — so the number here, the number on the artifact and the rows below cannot disagree again.

C-2 and C-132 moved 🟡/⬜ → ✅ together, because they were one defect with two row numbers: every URL-emitter deriving the locale from a different place.

C-39: the decision, and what shipped

C-39 was the last open row, and it was open for a decision rather than for work. The parts that are usually the hard part are already built and tested: lib/commerce/tax.ts holds rates as data in basis points with net + tax === gross as an invariant, orders carry line totals, and the order-confirmation email already renders an itemised document in two formats.

What is left is a question with three answers, and they are not the same feature.

1. A fiscal invoice is not a PDF. In Greece a retail invoice is not a document you generate — it is a document the tax authority issues you a MARK for, through myDATA. A PDF that looks like an invoice and was never transmitted is not an invoice; it is a liability with a logo on it. That track is already marked commercial on this roadmap and should stay there: it needs per-country logic, credentials, and a maintenance commitment that a GPL core cannot honestly make on an operator’s behalf.

2. A non-fiscal RECEIPT is a different thing, and it is safe. “Here is what you bought, what it cost, and what VAT was inside it” is a commercial document a buyer is entitled to keep, and no authority has to see it. That is a small, honest feature.

3. The PDF question has already been answered once on this roadmap. C-152 refused a server-generated PDF and gave the reason: a headless Chromium is roughly 300 MB on every self-host, for a file every browser already produces from a stylesheet. The same reasoning applies here unchanged — and an HTML receipt view with print rules is something a customer can save as a PDF, email, or hand to their accountant, with zero new dependencies.

The recommendation, accepted by the owner and now built: ship the receipt view in core (a per-order page, print-styled, reachable from the order confirmation), and leave anything that claims to be a fiscal invoice — myDATA, sequential numbering with legal meaning, credit notes — in the commercial track where the obligation can be met properly.

What shipped, and the three things that were not obvious going in.

The buyer is authorised by a signed token, not by an order number and an email in a query string. Those are personal data, and a URL is the one part of a request that lands in an access log, a proxy, a Referer header and a browser history. The token names one order, lives two years — a receipt that expires before the EU conformity window is not a receipt — and travels only in the buyer’s own confirmation email. It is its own signing purpose, so no other link in that inbox can be replayed as one.

An erased order is refused, however valid the token. The token was signed before the erasure and nothing revokes it. receiptIsAvailable refuses it, and the smoke test creates an order, opens its receipt, erases the subject, and re-opens the SAME link expecting a 404 — because a unit test proves the predicate refuses, not that the page asks. A bad token, an unknown order and an erased one return byte-identical 404s, so the page is not the order-number oracle order-lookup.ts exists to prevent.

There is no VAT-analysis-by-rate table, and that is a finding rather than a shortcut. It is the obvious thing to want and the data looks present — line_totals carries tax_rate_bp and tax_cents per line. It is not. tax_cents on the order is goods tax + shipping tax, and shipping_tax_cents is never persisted on the order. A per-rate table would therefore fail to add up to the VAT total printed beneath it on every order with taxed shipping, and a document whose own numbers disagree is worse than one that says less. Per-line RATE is shown instead, with a single reconciling total. Restoring the table means persisting shipping_tax_cents first.

The fourth audit — of the ROADMAP, not the code

The three previous rounds audited every ✅. This one audited every 🟡 and ⬜ — the rows claiming something is missing — because an unfinished row is a claim too, and nobody had ever checked one.

Fourteen readers, one per category, each required to cite file:line or say nothing. Fifteen of the fifty-seven were wrong, in three shapes:

  • Six were already done and had been for a while (C-19, C-32, C-34, C-96, C-108, C-110). Every one of them had been fixed as part of some other row’s work and nobody came back to update its own line.
  • Five had the right status and a false explanation. C-44 said “embeds work, add a privacy facade” — iframe is not on the sanitizer’s allow-list, so a pasted embed is destroyed on save; the facade has to ship with iframe support, not on top of it. C-62 credited “alt on upload”, and alt_text has one writer and no reader at all. C-113 claimed webhooks already covered most of ESP sync; WEBHOOK_EVENTS carries no subscriber event whatsoever.
  • Four understated what exists. C-146 said “prefix+contains today” when field weighting, phrase bonuses and AND semantics were all built and swept.

The lesson is the same one, pointed the other way. An unfinished row rots faster than a finished one, because finishing something makes you re-read its description while leaving it alone never does. A ⬜ that has been sitting for a month is a claim nobody has tested.

scripts/docs/regen-parity-artifact.py now rebuilds the published artifact from this file, so the two cannot disagree about a number again.

The second audit, of the fixes themselves

The thirteen false claims were fixed — and then the fixes were audited the same way, by ten reviewers with an independent skeptic on every finding. Forty-three survived, eight of them blockers, and the pattern was the same one again: a module built and tested and never called, a path fixed for the server-rendered route and not the API one, a promise in an email for a thing with no implementation behind it.

So four rows that had gone back to ✅ have moved to 🟡 or ⬜:

  • C-147lib/related.ts has 34 passing assertions and zero call sites.
  • C-96 — the API path gates on !user, and an API-key caller IS a user, so both live HEADLESS shops count nothing.
  • C-108 — the SMTP client writes DATA to the pre-STARTTLS socket, so it fails on port 587, the default.
  • C-34 — the confirmation email is real; the bank details it promises have no writer for the built-in bank-transfer method.
  • C-110 — double opt-in works; there is no unsubscribe, which the confirmation page promises.

C-2/C-132 was attempted and reverted once, then closed properly. The first attempt failed because the middleware rewrites a prefixed request, so every locale-prefixed URL the sitemap submitted was declared non-canonical by the page it pointed at. The fix was not to make the canonical follow the URL but to make every URL-emitter derive the locale from the same place: the record. buildPageView and the blog post route now canonicalise through recordLocale(post), which is what the sitemap and the feed already did — so a German post served at the unprefixed /blog/<slug> still names /de/blog/<slug> as its address, and the four signals agree by construction rather than by review.

Closing it also exposed the larger gap: nothing on the site LINKED to a prefixed URL. See C-129.

The lesson is not that the fixes were careless. It is that a fix is a claim, and a claim is worth what its audit is worth. These numbers now survive two adversarial passes; the ones before them survived none.

The 🔜 marker is gone: it meant “in a PR that has not merged yet”, and every PR it pointed at (#35 themes, #36 consent i18n, #37 content types) has been on main for some time. A status that means “soon” rots the moment it ships and nobody goes back to it — shipped or not shipped is the only distinction the table can keep honest.

The fifth audit — of a marathon, by five lenses and a skeptic each

The batch above was audited twice: once as it was built (26 findings, all fixed), then again over the whole 102-file diff after those fixes had landed. The second pass ran five lenses — scope, storage drivers, headless, CSS/CSP, and the new pure modules — with an independent reviewer instructed to REFUTE every claim. Sixteen claims, eleven confirmed and five refuted, and the confirmed set included a blocker that three separate lenses found independently:

/admin/posts/:id/edit was a hard 500. Its frontmatter called discourageIndexing() and its template called defaultLocale(), and both imports sat inside the page’s <script> block — a separate module. Every post edit page threw ReferenceError. Three layers were silent: // @ts-nocheck on that script kept astro check quiet, the smoke walk listed /admin/posts and /admin/posts/new and not the screen between them, and the existing scope guards worked off a hand-written list of helper names that neither symbol was on.

That shape — an import in the wrong Astro scope — has now cost seven bugs in one branch. It is the single most expensive thing about .astro files: the frontmatter and every <script> are separate modules, astro check reports the symptom as a tidiness hint, and the failure is either a 500 nobody’s tests reach or a silent ReferenceError in the browser. The answer is two checks that need no list of names:

  • an import inside a <script> whose name never appears in that script is in the wrong scope, and so is a frontmatter import used only in a script;
  • and any call to a function exported by src/lib or src/core must be imported in the scope that calls it — with the set of names derived from the source tree, so a helper added tomorrow is covered without anyone remembering it.

Both find the blocker when the fix is reverted. Every other fix in this round is mutation-tested the same way.

The other confirmed findings were the same shapes this document keeps recording: a check comparing a hyphenated slug against a spaced phrase, so every multi-word keyphrase failed a check about a URL that was nothing but the keyphrase; a read time returned as a string by one caller and a number by another into the same theme slot, printing “3 min read min”; a link checker that called every locale-prefixed URL broken in the same release that made the site emit them; and a post body that could not be emptied through the API, answering 200 while storing the old text.

The score went DOWN, and that is the point

It was 103 until every ✅ was checked against the code rather than against its own wording. Thirteen claimed a capability that does not exist, each confirmed by a second reviewer whose job was to refute the first. Five more were accused and rescued, so the check was not simply pessimistic.

The failures had one shape: the evidence cited itself. A row said “locale-aware sitemap — sitemap.xml.ts” and that file does not contain the word “locale”. A row said “server-side page-view counting — lib/page-view.ts” and that file renders a page; nothing in the repo has ever incremented post.views. A row said “reliable SMTP delivery” and there is no SMTP client of any kind. Reading the row told you nothing; only opening the file did.

A scoreboard is only worth the audit behind it. These numbers now mean a reviewer opened each file. Any row added later without that check should be assumed false.

Weighted by installs, the shipped set now covers every 4M+-install plugin’s core job. The last two holes — WXR import (C-90) and form building (C-20) — closed in Phase 2. The thinnest remaining areas are custom fields beyond ref/media (C-123, C-127, C-128), community features (C-136..C-143), and content utilities (C-45..C-47) — none of which blocks a migration, which is why they sit behind the adoption and trust waves.

Last reconciled against main after Phase 2 merged (PR #42). When a phase ships, flip the ROWS as well as the phase list below: the two disagreed for the whole of Phase 2, and a table that under-reports finished work is read as a backlog that does not exist.


The phased plan

Ordering principle: adoption blockers first, then trust, then reach, then depth. Each phase is sized to be one PR-train, each item lands with tests and OpenAPI updates per repo discipline (real CI commands; negative tests that fail without the fix).

Phase 1 — small, loud wins (days, not weeks)

Everything here rides existing rails and shows up on every page.

  • C-11 + C-12 JSON-LD structured data + breadcrumbs (Article, Product, Breadcrumb, Organization) — the last visible SEO gap vs. Yoast
  • C-47 Duplicate post/page — 4M installs say this matters daily
  • C-16 “Discourage indexing” toggle — one setting, saves staging sites from Google
  • Website: developers + documentation pages already live; link docs/ROADMAP.md and this file from /docs

Phase 2 — the adoption & trust wave (the WordPress-refugee release)

What someone leaving WordPress checks before committing.

  • C-90 WordPress WXR importshipped. Posts, pages, media (through the shared lib/media/ingest.ts, the same code the upload route uses), categories keeping their WordPress nicename, tags, publish dates, and a generated 301 map from old permalinks into the legacy-recovery engine. CLI, POST /api/import/wordpress, and /admin/import, all rehearsing by default and safe to re-run. WooCommerce shops come across through npm run import:woo, which turns the commerce switch on — never off. Authors are REPORTED rather than invited: creating invitations from an uploaded file would turn this endpoint into a way to make the server email thousands of addresses of an attacker’s choosing
  • C-20 + C-21 Public-write content typesshipped. writable: 'public' on a content type opens an anonymous POST guarded by a per-address submission limit, a honeypot and the local proof-of-work check; /forms/<type> renders the form itself, so CF7/WPForms parity really is a schema rather than a plugin. Read and write policies are separate axes, both deny-by-default: the pairing a job application needs — anyone may send one, nobody may read the others — is the one the builder makes easy, and the publish-everything pairing is the one it warns about
  • C-122 + C-124 Relation & media field kindsshipped. ref names its target collection and refuses a dangling link on create and on update; media stores the id and resolves a <field>_url on read. Fixing the switch they plugged into also uncovered that slug, email, url and date had been accepted and silently discarded since the builder shipped — all four are implemented, and the validator now fails closed
  • C-105 GDPR data-subject export/eraseshipped. /admin/privacy, admin only, both directions audited. Erasure anonymises orders instead of deleting them, because a shop must still be able to produce its accounts and Article 17(3)(b) says so; everything else about the person goes.
  • C-104 + C-102 Consent Mode v2 signals · consent audit trailshipped. All seven signals including the two v2 ones, mapped once and shipped to the browser as data; and a consent trail that proves a decision happened while holding nothing about who made it. The privacy story Complianz charges for is now complete: banner, prior consent, Consent Mode v2, a receipt trail, and data-subject access and erasure
  • C-86 S3-compatible off-site backup targetshipped. Any S3-compatible bucket, on the existing scheduler timer, with SigV4 signed by hand rather than by seventy packages and verified against an independent implementation. Archives what the active driver actually stores, and refuses a remote Turso install rather than uploading a stale db.json
  • C-60 Replace-media-in-placeshipped. The record keeps its id and every stored reference is rewritten to the new file; old files are removed unless another record shares them. C-59 (derivative rebuild button) dropped: a replacement already regenerates the full set, and rebuilding without a new file only matters after a change to the width list, which is a deploy
  • C-133 Translation-status dashboardshipped. /admin/translations, across both translation models, open to everyone who writes content. Flags the half-translated products that every locale-key check reports as done
  • C-155 + C-109 Scheduler admin screen · outbound email logshipped as one screen, /admin/operations. The scheduler says whether it is running in THIS process, not just enabled; the email log says who, what, how and whether it worked, and never the body. The log holds addresses, so the GDPR tooling covers it
  • C-41 Section composition UI (first pass)shipped. Reorder was already done; configure now works for PLUGIN sections too, with the variant class namespaced to the plugin so a manifest cannot mint a core class and inherit styling it does not own

Phase 3 — reach & polish

  • C-13 AI content analysis (readability/SEO hints via existing assistant) · C-134 draft-translation action
  • C-146 Weighted search · C-147 related posts · C-46 table of contents · C-45 table sections
  • C-142 Comments (moderated) — decide core vs. first-party plugin at spec time
  • C-87 + C-162 astrobaas migrate + CLI verbs (content/user/backup) · C-91 CSV import
  • C-123 Repeater fields · C-127 admin-defined settings groups · C-128 custom taxonomies
  • C-61 media folders · C-62 alt-audit → accessibility report · C-153 author/date archives
  • C-103 cookie declaration table · C-107 analytics charts in admin · C-112 editable email templates

Phase 4 — depth & monetization adjacency

  • C-32 Stripe adapter in core payments abstraction (IRIS stays commercial per its own track)
  • C-35 product reviews · C-39 invoicing/PDF receipts · C-111 newsletter campaigns
  • C-22/C-125 conditional logic (forms + fields) · C-23 public upload fields · C-126 flexible layouts
  • C-138 custom roles matrix · C-141 user switching · C-150 editorial workflow · C-149 manual ordering
  • C-168 npm theme distribution · C-170 child themes · C-114 popups as first-party plugin
  • C-79 spam scoring

Phase 5 — the long tail (each needs a champion before it’s scheduled)

AVIF (C-56) · video posters (C-64) · upload AV-scan hook (C-76) · file integrity (C-82) · staging docs (C-89) · RTL (C-135) · print/PDF (C-152) · per-request profiler (C-157) · white-label (C-159)

Explicitly commercial (never free-core)

Subscriptions (C-36) · funnels (C-38) · marketing automation (C-115) · membership (C-140) · paywall (C-144) — plus optical, IRIS, and the suite. Distribution per the established shape: one suite tarball + ASTROBAAS_PLUGINS_ACTIVATE, no licence-key gating in code.

Refused, with reasons on record

Page builder (C-42) · shortcodes (C-43) · cache plugins (C-52) · DB “optimizer” (C-66) · WAF-in-app (C-77) · hidden login (C-78) · IP-intel phone-home (C-81) · forums/social core (C-143) · admin-menu editor (C-158) · multisite (C-166) · arbitrary-code snippets. Each is either structurally unnecessary here, security theater, or a lock-in mechanism.


Sources

Status column audited against the working tree on 2026-08-29 (branch main + open PRs #33–#37). When a row says ✅ it names the file that proves it.