# What a host inspects

This is an audit of the smallest surface a custom application has to speak so a host of WordPress sites accepts the webroot. It is not a guide to forking WordPress software, and it is not a copy of that software. It is a map of the contracts hosts, backup tools, monitors, and WP-CLI actually inspect.

The point of writing it down is that the map is much smaller than the tree. Once the map exists, the same application can be written in PHP, Go, Rust, or anything else, as long as it still presents this shape.

WordPress 7.1 is the release whose contracts this page names.

## The three layers

The webroot is one directory a host of WordPress sites already knows how to operate. Three things occupy it. They are not three packages. Data sits inside adaptor folders because that is where backups already look. Minn is the exception: one folder of its own.

## Fork vs from scratch

Forking WordPress and building something WordPress-compatible are different jobs. They get mixed up because both produce a directory that a host will call "a WordPress site."

A fork keeps the tree. ClassicPress is the clean example: it kept the contracts and stayed a fork, so the license came with the files. Every `wp-includes/*.php` file is still WordPress's file, or a descendant of one. The host is happy because the files are there. The GPL is not optional, because the implementation *is* the files.

A from-scratch application keeps the contracts and throws the tree away. Nginx did this to Apache's HTTP. Samba did this to SMB. Wine did this to Win32. The surrounding world does not move. Compatibility is the product, and the implementation is a new work.

WordPress makes the second job look harder than it is, because what people call "the WordPress standard" is not a published protocol. It is a pile of PHP files. Hosts, WP-CLI, backup plugins, and migration tools do not read a spec. They `file_exists()` a path, regex a config, parse `$wp_version` out of `wp-includes/version.php`, and run `wp option get home`. If the file is missing, the host does not treat the directory as a WordPress site. If the file is present and the output shape matches, it does.

That is the trap. Meeting the standard looks like "ship all of those files." Shipping all of those files is a fork. The files are the *interface*. Their contents, except for a handful of parseable assignments and a boot chain that a regex can follow, are the *implementation*. A compatible application has to put the interface on disk. It does not have to put WordPress inside it.

> Including WordPress GPL code is how you fork. Matching the file names, the table names, the HTTP paths, and the CLI verbs is how a host of WordPress sites accepts the webroot.

Minn Engine is one implementation of this map. The map is the point of the page. Another runtime in another language can follow it without carrying any of Minn, and without carrying any of WordPress.

## The standard is a pile of files

WordPress 7.1 ships about 1,315 PHP files and 2,130 files in total, 1.8 million lines, once the bundled themes and plugins are left out. Roughly a thousand of those PHP files live under `wp-includes/`. Another 242 live under `wp-admin/`. Fourteen PHP files sit at the webroot.

There is no document that says "these fourteen are the host contract, and these thousand are the runtime." The installer drops the whole tree. Hosting panels treat the whole tree as the install. `wp core verify-checksums` hashes thousands of those files against wordpress.org. A missing `wp-includes/functions.php` is, to that world, a broken WordPress site.

For a host that is trying to *operate* the site, almost none of that tree is load-bearing. The operator needs to:

1. Detect that the webroot is a WordPress site.
2. Read database credentials without running the application.
3. Open a MySQL database whose tables match the `wp_*` schema.
4. Hit a few well-known URLs and not get 404s.
5. Run a handful of WP-CLI verbs whose stdout they already parse.
6. Back up files plus a database dump, and restore them somewhere else.

Those six jobs are the adapter. Everything else (the admin screens, the block editor packages, TinyMCE, the Customizer, the PHP functions a plugin would call) is software that happens to live in the same tree. A from-scratch application that wants a *web host* to operate it the way it operates a WordPress site implements the adapter. Looking like WordPress software to a *plugin* is a different, much larger contract, and it is not what this page is for.

### What a host is

In this document, "host" means the operational layer around the site, not a person in a browser:

- Platforms that host WordPress sites (Kinsta, WP Engine, and the rest) that detect the install, run WP-CLI, probe `/wp-login.php` and `/wp-admin/`, and inject drop-ins.
- Fleet tools (CaptainCore, Disembark) that inventory, back up, migrate, and search-replace.
- Backup plugins and migrators that regex `wp-config.php` and dump `wp-content/` plus the database.
- Uptime monitors that GET `/` and a short list of well-known PHP paths.
- WP-CLI itself, which decides whether the working directory "seems to be a WordPress installation."

Humans using `/wp-admin/` are not this audience. Minn answers that audience with Minn Admin. A from-scratch app can answer it however it wants.

## Files on disk

This is the layer that makes `wp core version` and a hosting panel treat the directory as a WordPress site. PHP-FPM and nginx decide it before any application code runs: a missing `.php` file is a 404, and a directory without an index is often a 403. The files have to exist as files.

### The boot chain

An unmodified `wp-config.php` is the whole trick. Backup tools, WP-CLI `config` and `db` commands, and host panels parse that file as text. They must not have to edit it. The stock file ends with `require_once ABSPATH . 'wp-settings.php'`. Whatever sits at `wp-settings.php` is the application.

```
index.php
  defines ABSPATH as this directory
  requires wp-config.php

wp-config.php          (the file WordPress generated; never rewrite it)
  defines DB_* and the eight KEY/SALT constants
  sets $table_prefix
  requires wp-settings.php

wp-settings.php
  boots your application
```

`index.php` must define `ABSPATH` *before* loading the config. WordPress itself does this in `wp-load.php`. Custom configs test `defined('ABSPATH')`. If the constant is missing, those configs fatally assume they are being included the wrong way.

### Shape files a host needs

| Path | Who reads it | What it must do |
| --- | --- | --- |
| `index.php` | The web server, WP-CLI (`extract_subdir_path`) | Define `ABSPATH`, require `wp-config.php`. |
| `wp-config.php` | Every backup and migration tool (regex), WP-CLI `config` and `db` | Exist, in the generated shape. Do not ship it. Do not rewrite it. |
| `wp-settings.php` | The last line of `wp-config.php` | Boot the application. Two lines is enough. |
| `wp-includes/version.php` | WP-CLI (`wp_exists()` is `file_exists` on this path; `core version` parses assignments by string search), hosting panels, backup tools | Assign `$wp_version`, `$wp_db_version`, `$tinymce_version`. The values name the release whose contracts you speak. The file contains none of that release's code. |
| `wp-login.php` | nginx (a missing `.php` is a 404 before PHP runs), hide-login plugins that `require ABSPATH . 'wp-login.php'`, monitors | Exist. Answer GET with a sign-in form or a 302. Never 404. |
| `wp-admin/index.php` | nginx (a directory without an index is a 403), monitors, panels | The directory and this file must exist on disk. A 302 away from `/wp-admin/` is fine. A 404 is the one status that tells a panel the directory is not a WordPress site. |
| `wp-cli.yml` | WP-CLI, if you intercept verbs before WordPress would load | Optional. The way a from-scratch app registers commands without shipping `wp-load.php`. |

`wp core version` does not boot the application. It includes `wp-includes/version.php` and prints `$wp_version`. A one-assignment file is a complete implementation of that verb:

```php
<?php
$wp_version = '7.1';
$wp_db_version = 61833;
$tinymce_version = '49110-20250317';
$required_php_version = '7.4';
$required_mysql_version = '5.5.5';
```

WP-CLI also wants `$wp_version >= 3.7` after including the file. Hosting panels string-search the same assignments. Matching the *format* (`$wp_version = '7.1';` on its own line) matters more than matching comments.

### `wp-content/`

| Path | Role |
| --- | --- |
| `wp-content/plugins/` | Plugin folders, or a single-file `slug.php`. Headers in the plugin file are how `wp plugin list` gets name, title, and version. |
| `wp-content/themes/` | Theme folders. `style.css` headers are how `wp theme list` gets name and version. `Template:` names the parent. |
| `wp-content/uploads/` | Media. Convention is `YYYY/MM/filename`. The web server serves these as static files. |
| `wp-content/mu-plugins/` | Must-use plugins. Hosts drop their own helpers here. They must be allowed to exist. |
| `wp-content/languages/` | Translation packs. Optional for detection. |
| Drop-ins (`object-cache.php`, `advanced-cache.php`, `db.php`, `maintenance.php`, `sunrise.php`, …) | Hosts inject these. Redis object cache is `object-cache.php`. They must be allowed to sit in `wp-content/`. Loading them is a runtime concern; forbidding the filename is how you get caught. |

Plugin file header, first docblock in the main PHP file:

```
Plugin Name: Example
Version: 1.0.0
```

Theme header, top of `style.css`:

```
Theme Name: Example
Version: 1.0.0
Template: parent-slug
```

`wp plugin list --format=json --fields=name,title,status,version` (CaptainCore's inventory) is a directory walk plus the `active_plugins` option. You do not have to run the plugin. You have to report it.

### Files that are deliberately not required

These exist in a WordPress tree and nothing in the host layer fails if your application answers them another way, or not as files:

| Path | Why it is in the tree | Compatible stand-in |
| --- | --- | --- |
| `wp-load.php`, `wp-blog-header.php` | WordPress's own include chain | Not needed if `index.php` loads the config. WP-CLI finds the root through `index.php` or the working directory. |
| `xmlrpc.php` | XML-RPC | The router can answer `/xmlrpc.php`. A file is nicer for nginx, not required if the host sends unknown `.php` through the front controller (Cove does; Kinsta does not). |
| `wp-cron.php` | Pseudo-cron | Same as xmlrpc: a file on hosts that 404 missing `.php`, or a route on hosts that do not. GET/POST should return 200 with an empty body. |
| `wp-comments-post.php`, `wp-mail.php`, `wp-links-opml.php`, `wp-trackback.php`, `wp-activate.php`, `wp-signup.php` | Legacy endpoints | Not what a host probes. |
| `readme.html`, `license.txt` | Version fingerprinting in the browser | Optional. Some scanners use them. They are not an operational surface. |
| The rest of `wp-includes/` and `wp-admin/` | The implementation | Placeholders so `require ABSPATH . 'wp-admin/includes/plugin.php'` resolves. Empty PHP files. Not WordPress code. |

The last row is the whole argument in miniature. Plugin code and host mu-plugins write `require ABSPATH . 'wp-includes/plugin.php'`. On a WordPress host that request is a file read, not a protocol. The compatible answer is an empty file at that path, with the symbols provided by your runtime (or not, if you are not running plugin PHP). The compatible answer is not "copy `plugin.php` out of WordPress." Copying it is a fork.

## wp-config.php

Treat this file as data. Hosts and tools read it with regexes. They do not want to run it, and a from-scratch installer must not rewrite it.

### What the regexes look for

```php
define( 'DB_NAME',     'database_name_here' );
define( 'DB_USER',     'username_here' );
define( 'DB_PASSWORD', 'password_here' );
define( 'DB_HOST',     'localhost' );
define( 'DB_CHARSET',  'utf8mb4' );
define( 'DB_COLLATE',  '' );

define( 'AUTH_KEY',         'put your unique phrase here' );
define( 'SECURE_AUTH_KEY',  'put your unique phrase here' );
define( 'LOGGED_IN_KEY',    'put your unique phrase here' );
define( 'NONCE_KEY',        'put your unique phrase here' );
define( 'AUTH_SALT',        'put your unique phrase here' );
define( 'SECURE_AUTH_SALT', 'put your unique phrase here' );
define( 'LOGGED_IN_SALT',   'put your unique phrase here' );
define( 'NONCE_SALT',       'put your unique phrase here' );

$table_prefix = 'wp_';

require_once ABSPATH . 'wp-settings.php';
```

A parser that only handles quoted string literals will miss real configs. Tolerate at least:

- `define('DB_NAME', getenv('WORDPRESS_DB_NAME'))`
- the official Docker image's `getenv_docker('WORDPRESS_DB_NAME', 'wordpress')` (env, then `{NAME}_FILE`, then the fallback)
- a `$table_prefix` that is not `wp_` (random prefixes are common; the allowed character class is `[A-Za-z0-9_]`)

Hosts also inject extra `define()`s (`DISABLE_WP_CRON`, `FS_METHOD`, `DISALLOW_FILE_EDIT`, `AUTOMATIC_UPDATER_DISABLED`, `WP_CACHE`, `WP_CONTENT_DIR`). Ignore unknown constants. Do not round-trip the file.

WP-CLI's `config get` and `db *` (the ones that run `after_wp_config_load`) strip the trailing require, eval the rest, and talk to MySQL with those credentials. If `wp-includes/version.php` exists and `wp-config.php` is parseable, `wp db export` works without your application existing at all.

## Twelve tables

The portable unit of a WordPress site is files plus a database dump. The dump is MySQL/MariaDB. The table prefix comes from `$table_prefix`. The twelve core tables, with the prefix stripped:

| Table | What lives there |
| --- | --- |
| `posts` | Posts, pages, attachments, revisions, navigation, templates, and every other post type. One table, discriminated by `post_type` and `post_status`. |
| `postmeta` | Per-post key/value. `meta_value` is often a serialized PHP blob. |
| `options` | Site options. `option_name` is unique. `option_value` is often a serialized PHP blob. `autoload` is `yes`/`on`/`auto-on` or `no`/`off`/`auto-off`. |
| `users` | Accounts. `user_pass` holds the password hash. |
| `usermeta` | Per-user key/value. Primary key column is `umeta_id`, not `meta_id`. Session tokens, capabilities, and locale live here. |
| `terms` | Term names and slugs. |
| `term_taxonomy` | Term plus taxonomy, description, parent, and `count`. Reads trust the stored count. |
| `term_relationships` | Object to `term_taxonomy_id`. |
| `termmeta` | Per-term key/value. |
| `comments` | Comments and the comment-shaped rows (`comment_type`). |
| `commentmeta` | Per-comment key/value. |
| `links` | The old blogroll. Empty on modern sites. Still there. |

Anything else (`wp_woocommerce_*`, `wp_wf*`, …) is plugin data. A host dump includes it. An adapter that only speaks core can ignore extra tables and must not drop them.

### Columns that tooling actually uses

**`posts`**

| Column | Notes |
| --- | --- |
| `ID` | Unsigned bigint, primary key. |
| `post_author` | User ID; `0` is authorless (wp-cli's default). |
| `post_date`, `post_date_gmt` | Local and GMT. `0000-00-00 00:00:00` is a real stored value. |
| `post_content` | The field. Blocks, classic HTML, or anything else. |
| `post_title`, `post_excerpt` | Text. |
| `post_status` | `publish`, `draft`, `future`, `pending`, `private`, `trash`, `auto-draft`, `inherit`. |
| `post_name` | Slug. |
| `post_modified`, `post_modified_gmt` | |
| `post_parent` | Pages, attachments, revisions. |
| `guid` | Stored as written. Search-replace tools skip this column on purpose. |
| `menu_order` | Pages. |
| `post_type` | `post`, `page`, `attachment`, `revision`, plus the rest. |
| `post_mime_type` | Attachments. |
| `comment_count` | Stored. Writers recount. |

**`options`**, the keys fleet tooling reads first: `home`, `siteurl`, `blogname`, `blogdescription`, `admin_email`, `template`, `stylesheet`, `active_plugins`, `permalink_structure`, `cron`, `site_icon`, `WPLANG`. `home` and `siteurl` are how a host knows the public URL. `template` / `stylesheet` are the active theme folder (and its parent). `active_plugins` is a serialized PHP array of `folder/file.php` paths.

**`users.user_pass`**, modern scheme, observed from running WordPress: a `$wp$` prefix, then bcrypt over `base64(hmac_sha384(password, "wp-sha384"))`. Legacy `$P$` (phpass) still exists on old rows. A host does not hash passwords, but a tool that logs in (or that copies users between sites) needs rows in this shape.

### Serialized PHP is forever

`option_value` and the `*_meta` value columns store PHP's `serialize()` output: `a:1:{i:0;i:5;}` for `sticky_posts`, similarly for `active_plugins`, role objects, and a long tail of plugin data. Search-replace tools (including `wp search-replace`) walk those blobs *without* a full unserialize, because a naive byte replace breaks the length prefixes.

A from-scratch application has to:

- Read the blobs it cares about with a tolerant parser, not `unserialize()` of untrusted data.
- Leave blobs it does not understand untouched.
- When it writes an array that WordPress would serialize, write the same format, so a later dump still loads in WordPress after an eject.

This is the one encoding in the adapter that is PHP-shaped even if the application is not PHP. The dump is the portable unit. The dump talks this encoding.

## HTTP probes

Monitors and panels request a short list of URLs that are not "a page on the site." Status codes matter more than bodies. A 404 on these paths is how you fail the Turing test.

| URL | Expected | Why |
| --- | --- | --- |
| `GET /` | 200 | The site is up. |
| `GET /wp-login.php` | 200 (form) or 302 (to your own sign-in) | Monitors, `wp user login` links, scripted sign-in. A 404 is fatal. |
| `GET /wp-admin/` and anything under it | 302 (login, or your admin) | Same. The directory must exist on disk so nginx does not 403 it first. |
| `GET /wp-cron.php` | 200, empty body | Hosts and plugins poke this. Honour `DISABLE_WP_CRON` if you do work here. |
| `GET /xmlrpc.php` | 405, `Allow: POST` | A 404 is a tell. POST can refuse (many hosts disable XML-RPC themselves). |
| `GET /robots.txt` | 200, four lines including the sitemap URL; or `Disallow: /` when the site is not public | Crawlers. Cheap to match. |
| `GET /wp-json/` or `GET /?rest_route=/` | 200, JSON index | The REST discovery document. |
| `Link: <{home}/wp-json/>; rel="https://api.w.org/"` | Response header on HTML pages | Discovery without fetching the index. |
| `GET /feed/` | 200, RSS 2.0 | Optional for a host, expected by feed readers. `<generator>` carries the recorded core version. |
| `GET /wp-sitemap.xml` | 200 | Optional for a host. |
| `GET /favicon.ico` | 302 to the site icon, or 404 | Optional. |

Pretty permalinks (`/hello-world/`) are a public-site concern. Hosts with nginx/Caddy already send unknown paths to `index.php`. `.htaccess` rewrite rules are for Apache; keep them if you find them, do not require them.

### REST, as a host sees it

The adapter does not need the full `wp/v2` surface for a host to be happy. It needs:

- The index at `/wp-json/` (`name`, `description`, `url`, `home`, `namespaces`, `routes`).
- Error objects of the form `{ "code": "rest_no_route", "message": "…", "data": { "status": 404 } }`.
- `GET /wp-json/wp/v2/settings` gated on `manage_options`, if anything in the fleet reads site title through REST.
- Cookie plus `X-WP-Nonce` authentication, if anything in the fleet logs in through REST.

The rest of `wp/v2` (posts, media, users, comments) is how an *admin application* speaks to the site. Minn Admin needs it. A host's backup job does not.

## WP-CLI

WP-CLI is MIT. The `wp` binary runs unchanged. Detection is:

1. Find a webroot (working directory, or `index.php` in a parent).
2. `file_exists( wp-includes/version.php )`. If missing: `Error: This does not seem to be a WordPress installation.`
3. Phase `before_wp_load`: commands that opted in. This is where a from-scratch app registers verbs, via `wp-cli.yml` `require:`.
4. Phase `after_wp_config_load`: `wp db *` and `wp config *`, using the parsed config. Needs `version.php` plus `wp-config.php`. Does not need your application.
5. Phase `after_wp_load`: everything else. On WordPress this loads the full runtime. On a from-scratch app that does not ship that runtime, refuse cleanly rather than fatal.

### Verbs fleet tooling actually runs

CaptainCore, Disembark, and typical host panels have been observed to call:

| Verb | Why |
| --- | --- |
| `wp core version` | Inventory. Parsed from `version.php`. |
| `wp option get home` / `option get siteurl` | Public URL. `--skip-plugins --skip-themes` and `--url=` are common. |
| `wp option update` / `option set` | Launch and migrate scripts. |
| `wp plugin list --format=json --fields=name,title,status,version` | Inventory. |
| `wp theme list` | Inventory. |
| `wp plugin install` / `theme install` / `activate` / `deactivate` | Updates and provisioning. |
| `wp user list` / `user get` / `user create` | Provisioning. |
| `wp user login <user>` | Magic-link sign-in (CaptainCore helper contract: a short token on `wp-login.php`). |
| `wp search-replace <old> <new> --all-tables --report-changed-only` | Domain changes on launch and migrate. Must walk serialized PHP arrays without breaking prefixes. Skip `guid`. |
| `wp db export` / `db query` / `db check` | Backups and health. WP-CLI's own, after config load. |
| `wp cache flush` | After writes. "Success: The cache was flushed." even when there is no object cache, so scripts do not abort. |
| `wp rewrite flush` | After permalink changes. |
| `wp maintenance-mode` (`activate` / `deactivate`) | Bookends updates. Implemented as `{ABSPATH}/.maintenance`. |

Stdout wording is part of the contract. Callers match `Success:` and `Error:` lines. Exit codes too: missing option is exit 1, missing plugin on `is-installed` is exit 1 with no output.

You do not need `wp post list` for a host. You do not need `wp core update` until you have a release of your own; that command becomes *your* updater, not a rewrite of WordPress's. `wp core verify-checksums` will fail against wordpress.org, because you do not ship those files. That failure is the truthful signal. Do not fake the hashes.

## Shared auth

A session is shared between the browser, the application, and whatever WordPress tooling still touches the site. Hosts that open the admin, magic-link logins, and REST clients all depend on the same cookies.

Cookie name: `wordpress_logged_in_` plus `md5(siteurl)`. Over HTTPS an additional `wordpress_sec_{hash}` cookie is set on `/wp-admin` and `/wp-content/plugins`; over HTTP it is `wordpress_{hash}`. The logged-in cookie is the one REST reads, path `/`.

Cookie value: `username|expiration|token|hmac`.

The HMAC key is derived from a four-character fragment of `user_pass`. Observed rule: for a `$wp$`-prefixed hash, the fragment is the *last four characters*; for legacy phpass `$P$`, it is `substr(hash, 8, 4)`. Getting this wrong produces a cookie that parses, finds the user, finds a live session, and still fails the HMAC, which looks like a salt mismatch.

The token's `sha256` must be a live key in the user's `session_tokens` usermeta.

REST nonce (`wp_rest` action): `substr(HMAC-md5(tick|wp_rest|uid|token, NONCE_KEY . NONCE_SALT), -12, 10)`, with `tick = ceil(time / 43200)`. Current and previous tick both accepted.

Status split, observed: cookie failure is `401 rest_not_logged_in`; nonce failure is `403 rest_cookie_invalid_nonce`. Clients use that split to decide between re-authenticating and refetching a nonce.

None of this requires WordPress source. It is a keyed-hash protocol over values in `wp-config.php` and `wp_usermeta`. Any language with HMAC-MD5, HMAC-SHA256, and bcrypt can implement it. Cross-acceptance (a cookie minted by the application authenticates against WordPress, and the other way around) is the proof the protocol was matched.

## The portable unit

Backup, migrate, staging, and "download a copy of my site" all assume the same bundle:

1. The files under the webroot, especially `wp-content/` and `wp-config.php`.
2. A SQL dump of every table with the site's prefix.

Restore is: put the files on a PHP host, import the dump, `wp search-replace old-host new-host --all-tables`, and visit `/wp-login.php`. A from-scratch application that can ingest that bundle and emit that bundle is operated the same way a WordPress site is, even if not one line of WordPress software runs.

Disembark's phar does not even read the shape files by name. It works from the database credentials it is given. UpdraftPlus reads `wp-config.php` and dumps `wp-content/` plus the database. CaptainCore's file hashes (`component-hashes`, `quicksave-fingerprint`) are bash over the filesystem.

The adapter's job on this layer is: do not invent a new layout, do not invent a new schema, do not invent a new encoding for options.

## What is not required

A host that can detect, back up, migrate, monitor, and run WP-CLI against the site does **not** need:

- The WordPress admin screens, or any file under `wp-admin/` except `index.php` (and empty placeholders if plugin PHP will `require` them).
- The block editor JavaScript packages, TinyMCE, the Customizer, Dashicons.
- The PHP function surface (`add_action`, `WP_Query`, …). That is how a *plugin* sees WordPress, not how a *host* sees it.
- XML-RPC methods. Many hosts disable them.
- Multisite tables (`blogs`, `site`, `sitemeta`, `blogmeta`, `signups`).
- `readme.html` matching wordpress.org.
- Byte-identical theme rendering.
- Core checksums against wordpress.org.

Those absences are how you tell a from-scratch application from a fork. A fork has the files because it *is* the files. A compatible application has the handful of paths the host reads, and is honest about the rest.

### What gives you away, if you care

| Signal | Who looks | Honest response |
| --- | --- | --- |
| `wp core verify-checksums` fails | Host integrity scans, CaptainCore | Correct: you do not ship WordPress. Do not impersonate the hashes. |
| Missing `wp-includes/functions.php` as a real implementation | Plugin PHP, some mu-plugins | Empty placeholder if you load plugin PHP; omit if you do not. |
| `GET /wp-admin/` is 404 | Monitors, panels | Always 302. |
| `GET /wp-login.php` is 404 | Monitors, magic links | Always exist as a file, then 200 or 302. |
| `wp-includes/version.php` missing | WP-CLI, panels | Always exist, with parseable assignments. |
| Rewritten `wp-config.php` | Backup tools | Never. |
| Extra tables dropped on migrate | Any dump/restore | Never. |
| Serialized PHP rewritten as JSON | Search-replace, eject back to WordPress | Never, for stored options and meta. |

Being operated like a WordPress site is not the same as hiding that you are something else. The truthful tells (checksums, `wp minn version`, a generator string that names your engine) can stay. The operational tells (404s, missing `version.php`, a rewritten config) cannot.

## Another language

The contracts above are file names, SQL, HTTP, and CLI stdout. None of them require the application to be PHP. Two constraints still bind.

**The host runs PHP.** On a platform that hosts WordPress sites, nginx sends `*.php` to PHP-FPM. A Go binary sitting at the webroot will not be executed. The compatible pattern is thin PHP at the well-known paths (`index.php`, `wp-settings.php`, `wp-login.php`, `wp-admin/index.php`, `wp-includes/version.php`) that boots, or proxies to, your runtime. Those stubs are original work: define a constant, require a config, hand off. They are not a fork.

**WP-CLI is a PHP phar.** Verbs you need either register at `before_wp_load` through `wp-cli.yml`, or they are WP-CLI's own (`config`, `db`) and only need the config file. You do not have to load a WordPress runtime to export a database.

The database is MySQL. The option encoding is serialized PHP. The auth scheme is HMAC plus bcrypt. HTTP is HTTP. A Rust service that speaks those, with five PHP stubs at the webroot, is operated by Kinsta the way Kinsta operates a WordPress site. A Rust service with none of the stubs is a different kind of host.

That is the whole license argument from the other direction. The stubs, the table names, the route paths, and the CLI verbs are the interface. Interfaces are not copyrightable expression. The thousand PHP files under `wp-includes/` are expression. A from-scratch application implements the first and does not copy the second. A fork copies the second and inherits the license. The reason WordPress feels like it can only be forked is that the interface and the expression are shipped in the same pile of files, and nobody wrote the interface down.

This page is that interface.

## How this was written

Captured from a running WordPress 7.1 (the "oracle") and from the tools that operate real sites: WP-CLI, CaptainCore, Disembark, Kinsta's file-on-disk behaviour, UpdraftPlus-style backup. Observed behaviour, not WordPress source. Comparing outputs is always legal. Copying `wp-includes/functions.php` is how you accidentally fork.

Minn Engine is one implementation of these contracts, in PHP, MIT licensed, with fixture suites that pin the shapes. The contracts live next to that engine. They are not the engine. If this page is useful, it is because someone can follow it in a language Minn does not speak.
