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:
- 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.storenamespaced to the owner, routes confined to/api/plugin/, semver-gated byrequiresCore) — nevereval, never a “Code Snippets” equivalent (WP #~40, 700k installs, and a top-5 source of white-screen incidents). - 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.
- 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.
| # | Family | Representative plugins (installs) |
|---|---|---|
| 1 | SEO | Yoast (10M+), Rank Math (3M+), AIOSEO (3M+), Redirection (2M+), Broken Link Checker (700k) |
| 2 | Forms | Contact Form 7 (10M+), WPForms (6M+), Ninja Forms (900k), Fluent Forms (600k), Formidable (300k) |
| 3 | Commerce | WooCommerce (7M+) + Stripe/PayPal gateways, CartFlows, Dokan |
| 4 | Page building & blocks | Elementor (10M+), WPBakery, Spectra (1M+), Kadence Blocks (400k), Classic Editor (5M+) |
| 5 | Performance & caching | LiteSpeed Cache (7M+), WP Rocket, W3 Total Cache (1M+), Autoptimize (1M+), WP Fastest Cache (1M+) |
| 6 | Image/media optimization | Smush (1M+), ShortPixel, EWWW (600k), Regenerate Thumbnails (1M+), Enable Media Replace (1M+), FileBird |
| 7 | Security | Wordfence (5M+), Solid Security (900k), Sucuri (800k), Limit Login Attempts Reloaded (2M+), WPS Hide Login (1M+), Two Factor |
| 8 | Anti-spam | Akismet (6M+), Antispam Bee, CleanTalk, reCAPTCHA integrations |
| 9 | Backup & migration | All-in-One WP Migration (5M+), UpdraftPlus (3M+), Duplicator (1M+), WP Migrate, Better Search Replace (1M+) |
| 10 | Analytics | Site Kit by Google (5M+), MonsterInsights (2M+), WP Statistics (500k), Koko Analytics |
| 11 | Privacy & consent | Complianz (800k), CookieYes (1M+), Cookie Notice (700k), WP Consent API |
| 12 | Email & marketing | WP Mail SMTP (4M+), MC4WP: Mailchimp (2M+), MailPoet (600k), Newsletter (300k), OptinMonster (700k), Popup Maker (700k), FluentCRM |
| 13 | Custom fields & types | ACF (2M+), CPT UI (1M+), Pods (100k), Meta Box |
| 14 | Multilingual | WPML, Polylang (700k), TranslatePress (400k), Loco Translate (1M+) |
| 15 | Users & community | User Role Editor (700k), Ultimate Member (200k), BuddyPress, bbBress, User Switching (200k) |
| 16 | Content utilities | Yoast Duplicate Post (4M+), Easy Table of Contents (400k), TablePress (800k), Relevanssi (100k), Advanced Editor Tools (2M+), Shortcodes Ultimate |
| 17 | Ops & admin | WP 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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 1 | Per-page title & meta description | ✅ | Post/page fields, rendered in BaseLayout |
| 2 | XML sitemap | ✅ | locale-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 |
| 3 | robots.txt management | ✅ | lib/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 |
| 4 | Canonical URLs | ✅ | absolute via public_site_url (PR #30) |
| 5 | hreflang alternates | ✅ | lib/hreflang.ts |
| 6 | RSS feed | ✅ | rss.xml.ts |
| 7 | Open Graph / Twitter cards | ✅ | BaseLayout meta |
| 8 | Redirect manager (301/302, per-site data) | ✅ | Legacy URL recovery (PR #29): admin map + regex rules |
| 9 | 404 monitor sorted by business impact | ✅ | top-404 report weighted by paid clicks — beyond what Redirection offers |
| 10 | 410 Gone for known-dead URLs | ✅ | recovery pages return honest status codes |
| 11 | Schema.org structured data (Article, Product, Breadcrumb, Organization) | ✅ | lib/structured-data.ts — Article, WebPage, CollectionPage, Organization, WebSite, BreadcrumbList, Product, from one tested module |
| 12 | Breadcrumbs | ✅ | 8th theme slot; the trail is built once and feeds both the rendered nav and the BreadcrumbList |
| 13 | Content 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 |
| 14 | Broken link checker | ✅ | lib/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 |
| 15 | llms.txt for AI crawlers | ✅ | llms.txt.ts — WP needs a plugin for this; we ship it |
| 16 | Search-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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 17 | Contact form with server-side validation | ✅ | api/contact.ts, honeypot + rate-limited |
| 18 | Submission storage & admin inbox (Flamingo) | ✅ | api/messages + admin screen |
| 19 | Email notification on submission | ✅ | api/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 |
| 20 | Custom 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) |
| 21 | Spam 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 |
| 22 | Multi-step forms, conditional logic | ✅ | showIf: {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 |
| 23 | File-upload fields on public forms | ✅ | lib/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 |
| 24 | Payment 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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 25 | Product catalogue with variants | ✅ | api/products, lib/product-fields.ts |
| 26 | Product categories & brands | ✅ | api/product-categories, api/brands |
| 27 | Cart & checkout | ✅ | lib/commerce |
| 28 | Orders & order management | ✅ | api/orders + admin |
| 29 | Customers | ✅ | api/customers |
| 30 | Coupons / discounts | ✅ | api/coupons |
| 31 | Shipping methods & zones | ✅ | api/shipping-methods |
| 32 | Payment gateways | ✅ | lib/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 |
| 33 | Stock / inventory tracking | ✅ | product fields |
| 34 | Sales notifications & order emails | ✅ | staff 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 |
| 35 | Product reviews | ✅ | Reviews 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 |
| 36 | Subscriptions / recurring billing | 🧩 | commercial territory, needs a PSP with tokenization first |
| 37 | Multi-vendor marketplace (Dokan) | 🚫 | wrong product; would distort the permission model |
| 38 | Sales funnels / checkout flows (CartFlows) | 🧩 | commercial, after Stripe |
| 39 | Invoicing / PDF receipts | ✅ | /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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 40 | Rich text editing with media embedding | ✅ | the 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 |
| 41 | Reusable structured sections on pages | ✅ | reorder 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 |
| 42 | Full drag-and-drop page builder | 🚫 | see thesis §2 |
| 43 | Shortcodes | 🚫 | string-splicing code into content is the WP mistake we refuse; sections (C-41) are the typed replacement |
| 44 | Embeds (YouTube, maps, socials) with privacy facade | ✅ | 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 |
| 45 | Tables 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 |
| 46 | Table of contents auto-generation | ✅ | lib/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 |
| 47 | Duplicate post/page (Yoast Duplicate Post, 4M installs) | ✅ | POST /api/posts/{ref}/duplicate — always a draft, own slug, no history |
| 48 | Revisions & undo history | ✅ | lib/revisions.ts |
| 49 | Scheduled publishing | ✅ | lib/scheduler.ts |
| 50 | Drafts & preview | ✅ | post status + preview |
| 51 | Content 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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 52 | Page caching | ✅ | the category is structurally unnecessary (thesis §3): Astro SSR + CDN-friendly headers do the job a caching plugin exists to do |
| 53 | Asset minification/bundling | ✅ | Astro build pipeline |
| 54 | Image compression on upload | ✅ | derivative pipeline (PR #30), measured ~2.1MB→~340KB per image set |
| 55 | Responsive derivatives (srcset sizes) | ✅ | fixed derivative set at upload, originals untouched |
| 56 | WebP/AVIF conversion | ✅ | WebP today; AVIF — Phase 3, encoder cost vs. gain to measure |
| 57 | Lazy loading | ✅ | cards 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 |
| 58 | Width/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 |
| 59 | Regenerate 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 |
| 60 | Replace 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) |
| 61 | Media 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 |
| 62 | Alt-text management & audit | ✅ | lib/image-dimensions.ts — applyImageAltText 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 |
| 63 | Image galleries | ✅ | |
| 64 | Video hosting guidance / poster frames | ✅ | A 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 |
| 65 | CDN base URL for media | ✅ | media-base.ts + public_site_url separation |
| 66 | Database optimization (WP-Optimize) | 🚫 | no autoload table to bloat; libSQL/relational drivers don’t need it |
7–8. Security & anti-spam (Wordfence / Solid / Akismet)
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 67 | Login rate limiting | ✅ | LOGIN_LIMIT 10/15min — do not relax (standing rule) |
| 68 | Two-factor authentication | ✅ | api/2fa |
| 69 | Password-reset hardening | ✅ | dedicated limiter, no user enumeration |
| 70 | Session management | ✅ | server-side sessions, revocation |
| 71 | CSRF protection | ✅ | double-submit cookie + header, all mutating routes |
| 72 | Security headers / CSP | ✅ | csp-config.ts, hash-based CSP (why consent.js is a single file) |
| 73 | Audit log (who did what) | ✅ | lib/audit.ts — WP needs a paid plugin for this |
| 74 | Rate limiting on public APIs | ✅ | lib/rate-limit.ts, measured limits in reference nginx |
| 75 | Input validation everywhere | ✅ | FieldRule + settings-validate; “no unexpected types or symbols” (standing rule) |
| 76 | Malware scanning of uploads | ✅ | 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 |
| 77 | Web Application Firewall | 🚫 | belongs in nginx/Cloudflare, documented in reference deploy, not in app code |
| 78 | Hide login URL (WPS Hide Login) | 🚫 | security theater; throttle + 2FA are the real controls |
| 79 | Comment/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 |
| 80 | API keys with scopes | ✅ | api/keys, api-key-scopes.ts — headless story WP doesn’t have |
| 81 | Brute-force IP intelligence sharing | 🚫 | phones home; against self-hosted ethos |
| 82 | File integrity monitoring | ✅ | 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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 83 | On-demand full backup | ✅ | api/backup + admin |
| 84 | Scheduled backups | ✅ | scheduler |
| 85 | Restore from backup | ✅ | tested 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 |
| 86 | Off-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) |
| 87 | Full-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 |
| 88 | URL search-replace on migrate | ✅ | unnecessary 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) |
| 89 | Staging environment guidance | ✅ | docs/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 |
| 90 | Import 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) |
| 91 | Import/export own content (JSON/CSV) | ✅ | 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 |
10–11. Analytics, privacy & consent
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 92 | GA4 integration | ✅ | ANALYTICS_PROVIDERS, consent-gated |
| 93 | Meta Pixel | ✅ | consent-gated, verified live in-browser |
| 94 | TikTok Pixel | ✅ | consent-gated, verified live in-browser |
| 95 | Privacy-first analytics (Plausible/Umami class) | ✅ | providers registered, consent-aware |
| 96 | Server-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) |
| 97 | Consent banner (accept/reject equal weight) | ✅ | banner plugin, hash-CSP compatible |
| 98 | Consent categories & granular choice | ✅ | necessary/preferences/analytics/marketing |
| 99 | Consent versioning & re-prompt | ✅ | CONSENT_VERSION, 6-month lifetime |
| 100 | Multilingual consent UI | ✅ | PR #36 — en/el/de, tested every-locale-complete |
| 101 | Script blocking until consent | ✅ | single gated loader; zero vendor bytes pre-consent (verified) |
| 102 | Consent 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) |
| 103 | Cookie scanner / declaration table | ✅ | lib/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 |
| 104 | Google Consent Mode v2 signals | ✅ | all 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) |
| 105 | Data-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) |
| 106 | Legal pages management | ✅ | lib/legal, api/legal |
| 107 | In-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-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 108 | Reliable SMTP delivery | ✅ | hand-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 |
| 109 | Email 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) |
| 110 | Newsletter signup + double opt-in | ✅ | double 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 |
| 111 | Newsletter sending / campaigns | ✅ | lib/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 |
| 112 | Transactional email templates | ✅ | |
| 113 | ESP sync (Mailchimp/Brevo class) | ✅ | 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 |
| 114 | Popups / opt-in overlays (OptinMonster, Popup Maker) | ✅ | /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 |
| 115 | Automated flows (abandoned cart) | 🧩 | commercial, needs C-32 first |
13. Custom fields & types (ACF / CPT UI / Pods) — the reason this document exists
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 116 | Admin-defined content types (CPT UI class) | ✅ | PR #37 — /admin/content-types, browser-defined, live without restart |
| 117 | Typed 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 |
| 118 | REST exposure per type | ✅ | /api/content/<type> immediately — ACF needs “show in REST” + extra plugin |
| 119 | Read-access policy per type | ✅ | deny-by-default staff / explicit public — and since Phase 2 an independent WRITE axis, so read and write cannot be conflated |
| 120 | Generated entries admin UI | ✅ | /admin/content/[type] from schema — CPT UI doesn’t do this at all |
| 121 | Plugin-declared types (code-first, ACF PHP registration) | ✅ | registerContentType() + manifest types; name precedence over admin types |
| 122 | Relation 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) |
| 123 | Repeater / 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 |
| 124 | Image/media field kind | ✅ | stores 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) |
| 125 | Conditional field display | ✅ | The 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 |
| 126 | Flexible 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 |
| 127 | Fields 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 |
| 128 | Custom taxonomies | ✅ |
14. Multilingual (WPML / Polylang / TranslatePress)
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 129 | Multi-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 |
| 130 | Translated admin UI | ✅ | en/el/de locale packs |
| 131 | Post/page translation linking | ✅ | translation groups + hreflang |
| 132 | Locale-aware sitemap/RSS | ✅ | both go through localePath + recordLocale. See C-2 |
| 133 | Translation 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) |
| 134 | Machine-translation assist | ✅ | {{target language}} is required and validated against the locales this install actually runs |
| 135 | RTL support | ✅ | 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 |
| 136 | Per-locale settings (site title, tagline) | ✅ |
15. Users & community
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 137 | Role-based access control | ✅ | admin/editor/staff, admin-access.ts route rules |
| 138 | Custom 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 |
| 139 | User profiles with avatars | ✅ | users API + admin |
| 140 | Front-end registration/login (Ultimate Member) | 🧩 | customers exist for commerce; general membership — commercial or plugin |
| 141 | User switching (support/debug) | ✅ | |
| 142 | Comments on posts | ✅ | A 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 |
| 143 | Forums (bbPress) / social network (BuddyPress) | 🚫 | verticals; third-party plugin territory |
| 144 | Content locking / paywall | 🧩 | commercial |
16. Content utilities
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 145 | Site search (public) | ✅ | api/search.ts + lib/text-search.ts |
| 146 | Weighted/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 |
| 147 | Related posts | ✅ | lib/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 |
| 148 | Reading time | ✅ | in post-cards.ts (PR #35) |
| 149 | Post ordering control | ✅ | comparePostsForListing 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 |
| 150 | Editorial workflow (draft→review→publish) | ✅ | |
| 151 | Broken-media report | ✅ | lib/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 |
| 152 | Print styles / PDF export of articles | ✅ | |
| 153 | Archive pages (author/date/category) | ✅ |
17. Ops & admin
| C-# | Capability | Status | Where / gap / phase |
|---|---|---|---|
| 154 | Health check endpoint (deep, non-200 on failure) | ✅ | PR #30 — probes image encode, uploads writable, plugins requested-vs-loaded, DB write, limiter store |
| 155 | Scheduled-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) |
| 156 | Maintenance mode | ✅ | lib/maintenance.ts |
| 157 | Query/performance monitor | ✅ | |
| 158 | Admin menu customization | 🚫 | menu is designed, grouped, i18n’d (PR #33); customization reintroduces the chaos it solves |
| 159 | White-label admin | ✅ | The 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 |
| 160 | Webhooks out (site events → URLs) | ✅ | api/webhooks + admin, HMAC-signed |
| 161 | REST API with OpenAPI contract | ✅ | openapi.json.ts, 145 contract checks in CI — WP has no equivalent |
| 162 | CLI (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 |
| 163 | AI writing assistant | ✅ | 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 |
| 164 | Reference deployment (nginx+systemd, measured limits) | ✅ | PR #30, real numbers from production incidents |
| 165 | Update/migration safety | ✅ | lib/migrations.ts, versioned |
| 166 | Multisite / multi-tenant | 🚫 | one instance per site; containers are today’s multisite |
| 167 | Theme switching, whole-site (the WordPress promise) | ✅ | PR #35 — slots incl. Home & PageArticle, tokens, 3 themes prove it; Phase 1 added Breadcrumbs as the 8th |
| 168 | Theme marketplace/distribution | ✅ | 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 |
| 169 | Declarative (no-code) themes | ✅ | manifest themes: tokens + data, no components needed |
| 170 | Child themes / per-site overrides | ✅ | 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 |
| 171 | Newsletter 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 |
| 172 | Landing 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 |
| 173 | Affiliate 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 |
| 174 | Analytics 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” —
iframeis 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”, andalt_texthas one writer and no reader at all. C-113 claimed webhooks already covered most of ESP sync;WEBHOOK_EVENTScarries 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-147 —
lib/related.tshas 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-transfermethod. - 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/liborsrc/coremust 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.mdand 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 import— shipped. Posts, pages, media (through the sharedlib/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 throughnpm 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 types— shipped.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 kinds— shipped.refnames its target collection and refuses a dangling link on create and on update;mediastores the id and resolves a<field>_urlon read. Fixing the switch they plugged into also uncovered thatslug,email,urlanddatehad 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/erase— shipped./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 trail— shipped. 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 target— shipped. 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-place— shipped. 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 dashboard— shipped./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 log— shipped 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
- WP.MD — Most Popular WordPress Plugins, daily ranking tracker
- Peligent — 50 Most Popular WordPress Plugins 2026 (ranked by installs)
- Website & Web Hosting School — The Most Popular WordPress Plugins in 2026
- WP.MD — WordPress Plugin Stats
- SmartWP — 30+ Best WordPress Plugins in 2026
- PluginScore — Most Installed WordPress Plugins
- WP Rocket — Best WordPress Plugins in 2026
- Capability descriptions for ACF, CPT UI, Complianz, WP Consent API, Redirection et al. from their WordPress.org listings (knowledge current to 2026).
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.