lumnik. / scenarios All scenarios · Français

WordPress announces 46 articles and serves 31: the run fails

Goal Read every article of a WordPress site, and refuse a partial read when a plugin hides some without changing the count.

WordPress's REST API pages by the Link header and publishes its count in X-WP-Total. lumnik follows the one and checks the other:

manifests/lab-docker-compose.yml
# A company's WordPress site: its articles are read through the REST API.name: lumnik-wordpress-siteservices:  db:    image: mariadb:11.4    environment:      MARIADB_DATABASE: wordpress      MARIADB_USER: wordpress      MARIADB_PASSWORD: wordpress-lab        # the lab's own, on this machine only      MARIADB_RANDOM_ROOT_PASSWORD: "1"    tmpfs: ["/var/lib/mysql"]  wordpress:    image: wordpress:6.6-apache    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: wordpress-lab      WORDPRESS_DB_NAME: wordpress      # Application passwords over plain http: WordPress allows them on a "local" site only.      WORDPRESS_CONFIG_EXTRA: "define('WP_ENVIRONMENT_TYPE', 'local');"    ports: ["127.0.0.1:${WORDPRESS_PORT:-9250}:80"]   # this machine only: the admin password is the lab's    volumes: ["site:/var/www/html", "./lab-mu-plugins:/var/www/html/wp-content/mu-plugins:ro"]    depends_on: [db]  cli:    image: wordpress:cli-2.11-php8.3    user: "33:33"    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: wordpress-lab      WORDPRESS_DB_NAME: wordpress    volumes: ["site:/var/www/html"]    depends_on: [db, wordpress]    profiles: [cli]volumes:  site:
manifests/lab-mu-plugins/members-only.php
<?php/* * Lab stand-in for a "members only" plugin: once the option lab_members_only is on, it hides the * articles of the "atelier" category from anonymous readers — after the query, through the_posts, * as such plugins do. WordPress has already counted them: X-WP-Total does not move. */add_filter('the_posts', function ($posts, $query) {    if (!get_option('lab_members_only') || is_user_logged_in() || !defined('REST_REQUEST')) {        return $posts;    }    return array_values(array_filter($posts, fn($p) => !has_category('atelier', $p)));}, 10, 2);
manifests/10-articles.yaml
apiVersion: connectors.lumnik.io/v1kind: Connectormetadata:  name: scenario-site-a  connector_type: rest-generic  scopes: [marketing]spec:  base_url: http://host.docker.internal:9250/wp-json  endpoints:    - id: articles      path: /wp/v2/posts?per_page=10      method: GET      pagination:        kind: link-header        response_path: $        total_header: X-WP-Total          # WordPress's own count of the articles      target:        kind: hybrid        table: ext.scenario_site_a        unique_key: $.id        promote:          - { name: slug, path: $.slug, type: text }          - { name: title, path: $.title.rendered, type: text }          - { name: published_at, path: $.date_gmt, type: timestamptz }
$ lm connector run scenario-site-a
run scheduled: 1afd408e-2f16-4511-9071-bba955852783

$ lm run get 1afd408e-2f16-4511-9071-bba955852783
ID        CONNECTOR        ENDPOINT  STATUS  IN  OUT  SKIPPED  DEAD-LETTERS  STARTED           ENDED             EVALUATED
1afd408e  scenario-site-a  articles  Failed  0   0    0        0             2026-10-30 20:48  2026-10-30 20:48  yes
✖ run failed: Endpoint 'articles': the source declared 46 rows but this run returned 31 — refusing to report a partial extract as a complete one
manifests/20-articles-member.yaml
apiVersion: connectors.lumnik.io/v1kind: Connectormetadata:  name: scenario-site-a  connector_type: rest-generic  scopes: [marketing]spec:  base_url: http://host.docker.internal:9250/wp-json  auth: { kind: basic, username: lumnik-reader, password_env: SCENARIO_SITE_APP_PASSWORD }   # a member's application password  endpoints:    - id: articles      path: /wp/v2/posts?per_page=10      method: GET      pagination:        kind: link-header        response_path: $        total_header: X-WP-Total          # WordPress's own count of the articles      target:        kind: hybrid        table: ext.scenario_site_a        unique_key: $.id        promote:          - { name: slug, path: $.slug, type: text }          - { name: title, path: $.title.rendered, type: text }          - { name: published_at, path: $.date_gmt, type: timestamptz }
hub
46 articles
49
article-45 · Article 45
48
article-44 · Article 44
47
article-43 · Article 43

evidence: replay.log, page-1-headers.txt, pages-members-only.txt, count.txt, newest.txt · transform v1

Limits

  • The witness is as good as the count: a plugin that also fixed X-WP-Total would hide its articles with nothing to show.
  • The hub reads what its account sees: an article reserved to another role is not read.
  • On a real site, the application password goes over https.
  • Demo WordPress site, synthetic articles; the "members only" plugin is a lab mu-plugin.

Website · articles

The WordPress site announces 46 articles.

A plugin hides 15 from visitors.

Demo WordPress site

The problem

The count does not move.

  • The plugin removes articles after the query.
  • WordPress still announces 46.
  • An import following the pages reads 31 and calls itself complete.

What is not changed

WordPress stays as it is.

  • The standard REST API, no extension.
  • A subscriber account and its application password.

What had to be written

pagination: { kind: link-header,
  total_header: X-WP-Total }

WordPress's count is the witness.

One 27-line connector.

Red

46 announced, 31 read: the run fails.

  • It gives both numbers.
  • No partial list reported complete.

Green

Read as a member: 46 of 46.

  • An application password, stored with lm secret.
  • The members-only articles are read too.

The limit

The witness is as good as the count.

  • If the plugin also fixed the count, nothing would show.
  • The hub reads what its account may see.

Try it

The scenario replays locally.

A real WordPress, every step published with its output.

This scenario runs on lumnik Open, free and open source.

Try lumnik Open →