The runtime ships a small REST surface for worker lifecycle, session auth,
command dispatch, an MCP door over its own commands, an SSE stream, and an
internal cache-health probe.
Application plugins register their own endpoints (dashboards, additional
streams) on top, and mount service Command_Interpreter_Nodes into the
dispatch endpoint's graph through the
newspack_nodes/request_graph_ready
hook.
Everything lives under one namespace, newspack-nodes/v1, registered by
Bootstrap::register_rest_routes() on rest_api_init. The order is
load-bearing: /health/runtime registers first so REST init completes even when
the runtime base directory is refused, and the others register only once
that base is available and ensure_runtime_wired() has run. /mcp is the one
route registered elsewhere: Nodes_Mcp_Door::wire() hooks its own
register_routes() on rest_api_init, as every MCP door does.
| Route | Method | Permission |
|---|---|---|
/workers/spawn |
POST | Internal HMAC token, or MANAGE + WP nonce + rate limit |
/auth |
POST | A signed-in user + per-user mint budget |
/oauth/register |
POST | Anyone, over HTTPS + per-address rate limit |
/oauth/token |
POST | A registered client, over HTTPS + per-client rate limit; the code and verifier or the refresh token authenticate |
/command |
POST | A signed-in user + per-user burst limit, then a per-command signature and each command's declared role or WordPress capability |
/mcp |
POST | A Bearer <handle>.<key> live session admitted at this door's resource + per-session rate limit; each tool needs its command's declared role |
/messages/stream |
GET | READ |
/health/runtime |
POST | Internal HMAC token |
The substrate is also its own client. HTTP_Out_Node POSTs batched JSONL
command envelopes to a remote spoke's /command (COMMAND_PATH) and
establishes the session that signs them at that spoke's /auth (AUTH_PATH);
SSE_In_Node pulls /messages/stream over cURL. Both speak the shapes
documented below, under bounds of their own — see
The substrate as client.
For the full architecture and rationale, see architecture-guide.md.
POST /wp-json/newspack-nodes/v1/workers/spawn
HMAC-validated worker spawn, used by every worker's _fleet peer scan, the
WP-Cron cold-start pass, and the worker's own self_respawn() chain. Not for
public callers — the token rotates every 10s and is per-site, so an external
call without a valid one returns 403 Forbidden.
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Worker type, which is also the topology name (see wp nodes types). The route's validate_callback refuses a type that is not declared by any ACTIVE topology, so an unknown one never reaches the handler. |
partition |
int | yes | Partition index, 0-based. Must be below both the type's active partition count and Spawn_Coordinator::MAX_PARTITIONS (8). |
nonce |
string | yes | Either the internal HMAC token or a WordPress nonce for the newspack_nodes_spawn_worker action — one field, two validators. |
The internal token is
Internal_Request_Token::generate( 'spawn', $now, Spawn_Coordinator::spawn_key() );
the diagram carries the construction. The Perl side of that contract lives in
services/gyrobase/sources/newspack-gyrobase/Gyrobase/Log.pm, and the two
halves of for_window() are held together only by t/spawn-token.t in that
separate repository.
{
"spawned": true,
"type": "job-worker",
"partition": 0
}Topology owners hook newspack_nodes/spawn_worker to build the right worker for $type and call
->execute(). The substrate registers Topology_Registry::spawn_worker on it
at load, which spawns any worker in the active set, and the runtime ships five
topologies under topologies/ — job-worker (the generic Job_Worker_Node
pool, per-partition), job-intake (drains the large-write job ingress on
substrate-only installs), settings-sync (a single-instance hub control plane,
num_partitions = 1), topic-probe (the per-worker consumer-stats sweep, included
by the others) and table-probe (the per-worker Table-stats sweep, included by a
topology that declares Tables). Application plugins register the rest. Every active topology,
builtin or application, spawns through this one hook — there is no separate
control-plane spawn path.
Job_Worker_Node is generic async-job dispatch: applications register
local and remote handlers through the newspack_nodes/job_handlers and
newspack_nodes/remote_job_handlers filters, and the entry's k field picks
the map. Each handler is called as ( string $id, array $parameters ) — $id
is the entry's top-level id, '' when absent. The worker runs the
newspack_nodes/job_worker/before_job FILTER
( $run, $handler, $id, $message ) and fires the …/after_job action
( $handler, $id, $outcome ) around each job, so applications can establish and
tear down per-job request context. A before_job listener returning false
DECLINES the job — the handler never runs — which is how a plugin refuses work
addressed to another host; only an explicit false declines, so an
action-style null return carries on. Shorter callables ignore the extra
arguments.
{ "code": "invalid_partition", "message": "Partition out of range for worker type", "data": { "status": 400 } }The handler refuses a partition that is negative, at or above
MAX_PARTITIONS, or past the type's active partition count. This is the first
of its three refusals.
{
"code": "invalid_token",
"message": "Invalid spawn token",
"data": { "status": 403 }
}This is the normal response for unauthenticated callers.
{ "code": "fleet_held", "message": "fleet held since 2026-09-03T12:00:00+00:00; run `wp nodes start` to resume", "data": { "status": 409 } }A deploy hold stands in Spawn_Coordinator::HOLD_OPTION, held at the endpoint
rather than at each spawner because this is the one gate they all cross.
{ "code": "spawn_throttled", "message": "job-worker.p0 spawned less than 15s ago", "data": { "status": 429 } }The {type}|{partition} pair is inside the shared throttle window; see
Rate Limiting.
This section covers the spawn endpoint only; /command's per-command signing
model is Command Signing below.
The handshake above draws the spawn endpoint's dual-mode auth
(Spawn_Controller::check_permission) as two validators of one
nonce field. There is no
env-var bypass — NEWSPACK_NODES_WORKER_TYPE and _PARTITION are written to
$_SERVER after auth passes (see Worker Identity Tags)
and are consulted nowhere on the permission path.
Application plugins adding their own endpoints should gate them through
Capabilities::can() rather than a bare current_user_can( 'manage_options' ),
so a site that installs the granular capabilities keeps its read-only callers
read-only. See newspack-event-logger-nodes/docs/API.md for the
application-side patterns.
POST /wp-json/newspack-nodes/v1/health/runtime
Narrow internal loopback endpoint wp nodes doctor uses to read the two
health rows whose answer depends on the PHP runtime asking: the cache backend
the WEB runtime selects, and whether every sources:<name> its active
topologies read resolves in its log-source registry, whose built-ins read the
runtime's own ini and constants. A probe run under WP-CLI can report a posture
that visitors and workers never get, because WP-CLI selects its own backend and
reads its own php.ini. It is not a general cache API and not for public callers.
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | yes | A lowercase 64-character HMAC-SHA256 token for the health-runtime purpose. |
The body is form-encoded and carries only token, which is
hash_hmac( 'sha256', "newspack_nodes_health-runtime:{$window}", wp_salt( 'nonce' ) ),
built by Internal_Request_Token::generate() with PURPOSE_HEALTH_RUNTIME,
where $window = floor( time() / 10 ). The endpoint accepts the current and
immediately previous windows. Purpose separation means a spawn token cannot
authorize this route, and a health-runtime token cannot authorize worker spawn.
WordPress REST enforces the required token argument. Omitting it returns HTTP
400 with code rest_missing_callback_param, before the permission callback
and before Bootstrap::fleet_gate(), so controller validation never runs either.
When a token is supplied, the fleet gate applies before controller validation:
on multisite, only the main fleet site may use the route, and a subsite receives
403 Forbidden. On the fleet site, a malformed, expired, future-window or
wrong-purpose token receives 403 Forbidden with code invalid_health_token.
Both refusals answer under that one code and echo nothing of what was
presented, so a caller learns neither which check failed nor how close its token
came.
The route accepts no caller-selected cache key or value. Extra key or value
input is never used by the probe and never returned; the server generates and
removes its own random probe entry.
After permission succeeds, the route always returns HTTP 200 with exactly two
canonical results, Health_Checks::runtime(), in this order:
[
{
"id": "cache-backend",
"label": "Cache backend",
"status": "good",
"messages": [
"Cache backend APCu add/read/delete round trip succeeded."
]
},
{
"id": "log-sources",
"label": "Log sources",
"status": "good",
"messages": [
"Every file the active topologies' File_Tails follow resolves on this host."
]
}
]The four fields of each are fixed: id, label, status and messages, and
messages holds one non-empty diagnostic string of at most
Health_Checks::MESSAGE_BYTES (512) bytes holding no control, line- or
paragraph-separator character. The cache row is good or critical. The
log-sources row is critical when an active topology's File_Tail follows a
file that does not resolve on this host, such as a sources:<name> the
registry lacks, naming the topology and carrying the load's own refusal, and
good otherwise. (wp nodes doctor synthesizes recommended for both rows when the
loopback reply cannot be verified whole.) A failed check is a canonical
critical result in the same HTTP 200 response: health severity is payload
state, not a transport failure.
The spawn handler sets two $_SERVER keys once auth passes, before it fires
newspack_nodes/spawn_worker:
$_SERVER['NEWSPACK_NODES_WORKER_TYPE'] = $type; // e.g. "job-worker"
$_SERVER['NEWSPACK_NODES_WORKER_PARTITION'] = (string) $partition; // e.g. "0"Once Worker_Base::execute() holds that worker's lock, it fires
newspack_nodes/worker_identified with the same pair, so a listener that read
the keys before they were set — a request logger naming the process at its
start — learns the worker's identity mid-process. A spawn that loses the lock
race never becomes the worker and announces nothing, and neither does
wp nodes run, which does not set a tag. A listener that needs the worker id spells
it with CLI::worker_id( $type, $partition ), the inverse of
CLI::parse_worker_id().
These are process-identity tags, not credentials. Three things read them:
Core::argv0()puts the worker type in every log line's midfix, so a firehose line names the process that wrote it instead of the SAPI.Consumer_Node::checkpoint_frame_extra()stampsworker_typeinto each offsetlog checkpoint, which is how the dashboard labels a reader by the fleet it belongs to.- Consumer plugins keep worker self-traffic out of the global request counters.
Log_Managerin event-logger-nodes allow-lists both keys in its environment capture and reads the type to tag its ownprocess (start)frame;Request_Builder_Nodereads the type back off that capture and marks the recordis_worker, whichFlame_Builder_Nodefiles per URL but drops from the global roll-up. Nuclear-gyrobase reads the partition to build the lock lease it hands the Perl engine.
Bootstrap::reconcile_fleet() writes the same keys ('reconcile' and '0') so
the WP-Cron reconciliation pass is tagged consistently with topology workers,
and a consumer may tag a request of its own the same way — cache-cozy's
mu-plugin writes 'cache-cozy' on its warm loopback so that deliberately
expensive render lands outside the global stats. The value is a stats dimension,
not a worker type: nothing compares against the literal. What every writer
shares is that a client can never reach the key, since PHP exposes request headers
under an HTTP_ prefix — which is why event-logger-nodes' Auto_Tuner_Node
treats the type's mere presence as proof of worker context before it rewrites
the ruleset.
The spawn endpoint's two limits are in the handshake above: one call per
Spawn_Controller::RATE_LIMIT_S (2 seconds) per user on the WordPress-admin path,
metered by Rate_Limit below and answering 429 rate_limited, and the 15-second {type}|{partition} throttle
(Spawn_Coordinator::MIN_SPAWN_INTERVAL_S) every spawner shares, taken by
Spawn_Coordinator::admit_spawn() with one atomic add() in the shared cache, so of
concurrent spawns for one slot exactly one is admitted and the rest answer
429 spawn_throttled, or checked and then set in a transient where there is no shared
cache. The key lives one second past the window, because memcached expires in whole
seconds and a key stored for exactly 15 could lapse early. Only an admitted spawn takes
it: a spawn refused for a bad partition or a hold leaves the slot free. Spawning is how the fleet
revives itself, so where the shared cache cannot meter the admin path, the spawn
is admitted unmetered, with one rate-limited warning, rather than refused.
The /command endpoint applies its own per-user limit
(HTTP_In_Node::check_permission): RATE_LIMIT_BURST = 30 POSTs per
RATE_LIMIT_WINDOW_S = 1 second per user, answering 429 rate_limited on
overflow. Memcached counts expiry in whole seconds, so at one second a slot
frees within the second it was claimed in, and the budget is close to one
bucket per cache tick; what the limiter adds over a transient counter
is the atomic claim. When neither memcached nor APCu answers, the door admits
and logs one rate-limited warning, because every dashboard, Status poll and
hub push rides /command, and a dead cache must not take them all down. The
budget is tunable through the newspack_nodes/command_rate_limit
filter, clamped to 1 through Rate_Limit::MAX_BURST (256). The capability is
checked first, so an unauthenticated flood spends no slots.
The limit is Rate_Limit::claim( $name, $burst, $window_s ),
which the spawn endpoint, the OAuth doors and an
application door meter through too, as event-logger-nodes' MCP route does. Each admitted call claims one of $burst
slots with the shared cache's atomic add() under a $window_s TTL, so two
concurrent requests cannot both take the last slot. Each slot frees on its own
clock, but in whole seconds: memcached frees it between $window_s - 1 and
$window_s seconds after its claim, and APCu between $window_s and
$window_s + 1. It answers Rate_Limit::ADMITTED, THROTTLED or
UNAVAILABLE — the last when there is no shared backend or the slot read
fails — and each door decides what an unmetered call gets: /command and
spawn admit it through Rate_Limit::admit_unmetered(), which warns once per
door, and the OAuth doors and MCP refuse it with a 503. A burst outside 1 to MAX_BURST, a window
below 1 or a name holding whitespace throws.
Passing /command's permission callback authenticates the request; it signs
nothing. Every command inside the batch must carry its own HMAC, stamped by the
node that minted it, or the runtime refuses it. Ingress never signs on a caller's
behalf, because that makes the boundary an oracle where arrival implies authority
(ADR-15).
Only the minter's own signature counts.
POST /wp-json/newspack-nodes/v1/auth
Issues a session: a random signing key under a random handle
(Command_Auth::mint_session()). Gated by the fleet gate then a signed-in
user — a scope is a ceiling, so a read-only user minting a manage session
still gets a read-only one, and a user holding no role gets a none session,
which covers no role and runs only the commands declaring a WordPress capability
that user holds (see Service CIs). A labelled request from a user
holding no role answers 403 invalid_scope, and Command_Auth::mint_session()
refuses a labelled none session outright. Each user's mints claim
Auth_Controller::RATE_LIMIT_BURST
= 30 slots a minute (RATE_LIMIT_WINDOW_S = 60) under auth:<user>, raised by
the newspack_nodes/auth_rate_limit filter and clamped to Rate_Limit::MAX_BURST;
over budget answers 429 rate_limited, and with no cache to meter in the mint is
admitted unmetered with one warning, as /command does. The handle and the key are both generated server-side; caller
entropy is unverifiable, and a caller-chosen handle could collide with or
fixate a live session.
| Field | Meaning |
|---|---|
scope |
read, tune or manage. Defaults to manage, and is CLAMPED to the highest role the minting user holds, so the returned scope states authority rather than a request. An unrecognised value answers 400 invalid_scope. A request carrying a label is a session a person keeps, so it may hold only a Capabilities::LABELLED_SCOPES scope, read or tune: it defaults to tune, and manage answers 400 invalid_scope. An unlabelled request, a dashboard's or a hub's own session, may hold manage. |
label |
How the session shows up in the Sessions tab. An empty label keeps it out of the listing. What is stored runs through sanitize_text_field(), loses every C0 and C1 control character and DEL, which that function keeps, and is then truncated to Sessions::MAX_LABEL = 64 characters, so a listing can come back shorter than what was sent, or stripped of markup — the cap is there so a listing cannot be used as storage. sessions create echoes the label as SENT, so the two can disagree the moment the next list runs. |
ttl |
Lifetime in seconds, clamped to [ Command_Auth::SESSION_TTL_MIN_S, SESSION_TTL_MAX_S ] = [60, 86400]. Defaults to SESSION_TTL_S = 3600. |
A session the store cannot hold answers 503 session_store_unavailable:
Command_Auth::mint_session() throws
Session_Store_Unavailable
when the durable session store — a row of this install's
nodes-sessions-<install> Table on the backend sessions_backend names, SQLite
by default — will not open or refuses the row, and
issue() catches that type alone,
logging the server's reason and keeping it out of the
response. mint_session() also throws InvalidArgumentException on a scope
neither on the READ/TUNE/MANAGE ladder nor none, which issue() refuses first
with the 400 above; a client cannot ask for none, which issue() grants alone. A hub whose HTTP_Out handshake fails logs
auth failed at spoke: HTTP <status> <code>, naming the code the spoke's
body carries — HTTP 503 session_store_unavailable for this outage — or the
bare status when it carries none, and holds its batch for the next attempt.
{
"handle": "5f2b...(32 hex chars)",
"secret": "9ac4...(64 hex chars)",
"scope": "read",
"expires_in": 3600,
"now": 1735689600
}The signing key is disclosed as secret because that is the field name
Core::is_secret_property() recognises. Every
redactor on both sides of the wire asks that one rule, so a reply rendered into
a drop-audit line or persisted to a browser's transcript is masked by its name
alone.
The key cannot be recovered from the Sessions listing. The session also records the minting user; without it, a credential presented outside a browser would authenticate and then act as nobody.
The minting node signs before the command leaves the process — the browser's
Shell, a dashboard hook, or a PHP caller of Command_Auth::sign() /
sign_for(). The result rides under VALUE.auth:
{ "auth": { "nonce": "b91e...(32 hex chars)", "sig": "7cd0...(64-char hex HMAC)", "handle": "5f2b...(session handle)" } }Repointing an envelope at another handle only makes the signature stop matching, because the handle stays outside the signed string.
The /command request process installs Command_Auth::verifier() as every
interpreter's authorize policy; the five checks, their order and the ceiling
each outcome installs are in the diagram above. Beside the ceiling it
installs Capabilities::$session_label: the session's label, '' for a
session minted without one, and null for the per-site secret or a refusal;
interpret() restores both when the command ends. A refused command replies
TM_COMMAND|TM_ERROR carrying unauthorized: <verb> through the normal
TO=FROM path, and the containing /command batch answers 401 instead of
202 or 200 (see Command Dispatch below). The drop audit is
the rate-limited line Node::drop_message() writes as verification failed: …, which goes to the PHP error log alone, never into the response, an
SSE stream, a worker's IPC output or the dmesg ring. When the command named a
live site-wide session, the line ends handle: <handle>, so an operator can
tell which client is misconfigured; it never carries the key, the signature,
the nonce or the command's payload. The handle rides in the line's tail, outside
the throttle key, so the log keeps one line per reason per process, so once per
POST in the request scope, naming the first session refused for it, however
many sessions a caller mints. A dead or
grant handle is named nowhere. Every command the
verifier judges and refuses — a wrong key, a stale or malformed envelope, a
dead or grant handle — therefore answers the same bytes, naming neither the
cause nor the process. An environment fault still reports to stderr, and so
into the body: no nonce store, reached only after the signature verifies, and
a session store that will not answer.
The site is its own OAuth 2.1 authorization server, so an MCP client such as
Claude signs in through the browser instead of being handed a session.
Oauth_Server decides every answer and
Rest\Oauth_Controller sends it;
Oauth_Store keeps the clients, codes,
grants and refresh tokens beside the sessions. Every endpoint refuses a request
that did not arrive over HTTPS with 400 invalid_request, and none
authenticates a user: on a multisite subsite the two routes answer 403
newspack_nodes_not_fleet_site and the documents are left to WordPress.
A parse_request handler, Oauth_Controller::serve_well_known(),
answers two paths outside the REST namespace as JSON, with
Access-Control-Allow-Origin: * and Cache-Control: no-store, and ends the
request; every other path passes through to WordPress untouched, and the hook
tests for .well-known/oauth- before it loads either OAuth class. It reads
WP::$request, which WordPress fills only under pretty permalinks, and a
declared resource's trailing slash is ignored, because WordPress trims it.
| Path | Document |
|---|---|
/.well-known/oauth-authorization-server |
RFC 8414: issuer (the home URL, no trailing slash), authorization_endpoint (wp-admin/admin.php?page=newspack-nodes-authorize), token_endpoint, registration_endpoint, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported |
/.well-known/oauth-protected-resource/<path> |
RFC 9728, for a resource whose URI path is <path>: resource, authorization_servers: [<issuer>], scopes_supported, bearer_methods_supported: ["header"]. A path that does not belong to any declared resource answers 404 invalid_target |
scopes_supported is Oauth_Store::SCOPES,
read, tune and manage. A door declares each resource it protects through the
newspack_nodes/oauth_resources filter, and names the document's
URL with Oauth_Server::resource_metadata_url( $resource ).
Dynamic client registration (RFC 7591), for public clients only. The JSON body,
and nothing else (a query string is not read), names client_name and redirect_uris: one to five URIs, each https:// or
http:// on localhost or 127.0.0.1, with no userinfo, fragment or
whitespace, and none that wp_sanitize_redirect(), which wp_redirect() runs,
would alter: a quote, a brace or a non-ASCII byte is refused, because the
approval page would match the URI and then send the browser elsewhere. A
token_endpoint_auth_method other than none is refused. The
answer is 201 with client_id, client_id_issued_at, client_name (kept as
a session label is), redirect_uris, token_endpoint_auth_method: "none",
grant_types and response_types: ["code"]; the server issues no secret. A missing
name answers 400 invalid_client_metadata, a refused URI list 400
invalid_redirect_uri. A client lapses 180 days after registering.
Ten registrations an hour are admitted per caller address
(oauth-register:<ip>); the eleventh answers 429 rate_limited. Claude
Desktop and claude.ai register from Anthropic's servers, not from the person's
machine, so every connector set up on one site shares that budget: an eleventh
connection added within the hour is refused until the window passes.
authorization_endpoint is a wp-admin page,
wp-admin/admin.php?page=newspack-nodes-authorize, hidden from the menu and
open to any user holding read's capability
(Admin\Oauth_Consent). wp-admin
sends a visitor who is not logged in to wp-login.php and back. The query
names:
| Parameter | Must be |
|---|---|
response_type |
code |
client_id |
A live registration |
redirect_uri |
Byte for byte one the client registered |
code_challenge_method |
S256 |
code_challenge |
Present |
state |
Present |
resource |
One an application declares through newspack_nodes/oauth_resources |
The page also refuses plain HTTP, a multisite subsite, a parameter that would
not come back unchanged through the form's hidden fields (an entity such as
& in a state, invalid UTF-8, or a control character such as a line
break, which a redirect strips), and a user who can grant none of the scopes. A refusal is shown on the page and nothing is redirected, so an
unregistered redirect URI is printed, never followed. Otherwise the page names
the site, the client's self-declared name and, in large type, the redirect
URI's host; a host other than claude.ai, claude.com, localhost or
127.0.0.1 carries the warning "This connection sends its credential to
<host>, which is not Claude." It offers the scopes of Oauth_Store::SCOPES
that Oauth_Server::can_grant() admits, each named and described by
Capabilities::label(), as the Sessions tab describes it, the broadest
checked, and asks for
a connection name of up to Sessions::MAX_LABEL (64) bytes, which labels the
grant and every session minted from it.
Approve and Deny post to admin-post.php with
action=newspack_nodes_oauth_decide and the nonce field
newspack_nodes_oauth_nonce. The handler dies on a missing or wrong nonce,
then checks every parameter again and dies on any refusal, or on a scope the
user cannot grant. Deny, or a post without a decision, redirects to the
registered URI with error=access_denied&state=<state>. Approve naming no
connection returns to the page, which asks for one; otherwise it issues a code
that lives 60 seconds, bound to the client, the redirect URI, the challenge,
the resource, the user, the scope and the name, and redirects to the registered
URI with code=<code>&state=<state>. Both redirects go through
wp_redirect(), because wp_safe_redirect() refuses every host but the site's host.
A form-encoded token request naming grant_type and client_id. Only the form
body is read: a credential in the query string would land in access logs, so a
request carrying its fields there is answered as if it named none. A
client_id that is not held by any live registration answers 401 invalid_client, which
tells the client to register again; it spends no rate-limit slot. Thirty
requests a minute are then admitted per client (oauth-token:<client>).
grant_type |
Also names | Checks |
|---|---|---|
authorization_code |
code, redirect_uri, code_verifier, resource |
The code is taken once, so a refused exchange spends it too. It must have been issued to this client for this exact redirect_uri and resource, and base64url( sha256( code_verifier ) ) must equal its challenge. A new thirty-day grant opens |
refresh_token |
refresh_token, optionally resource |
The token rotates through Oauth_Store::rotate(): a retry within 60 s is answered with the same pair, and a token reused later revokes its grant. A token issued to another client, or a resource the grant was not issued for, is refused and spends nothing, a retry inside the 60 s included; a request without a resource is bound to the grant's resource |
Before either mints a session, the grant's user is signed in for the one check
that they still hold its scope (Oauth_Server::can_grant( $scope ), the check
the approval page offers scopes by),
then signed out again. A user who lost it ends the grant and every session it
minted. The answer is 200:
{ "access_token": "<handle>.<secret>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "<64 hex>", "scope": "tune" }expires_in is less than 3600 in a grant's last hour. Every answer carries
Cache-Control: no-store and Pragma: no-cache.
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
A missing grant_type or field, or plain HTTP |
| 400 | unsupported_grant_type |
Any other grant |
| 400 | invalid_grant |
A code or refresh token unknown, expired, spent, or issued for another client, redirect URI or resource; a user who lost the scope |
| 401 | invalid_client |
No such client |
| 429 | rate_limited |
The client's budget is spent |
| 503 | rate_limit_unavailable |
Neither memcached nor APCu can meter the request, so it is refused, as the MCP door refuses one |
| 503 | temporarily_unavailable |
The session store did not answer; its cause goes to the log |
The registration route answers the same 503s, and both routes carry the error's
description in error_description.
A plugin serves an MCP server over commands it already declares by
subclassing Mcp_Door and calling wire()
(ADR-38).
The route sits under the plugin's own namespace, so the substrate's own door,
described below, answers at /newspack-nodes/v1/mcp and
an application's at its namespace instead:
| Route | Method | Permission |
|---|---|---|
/<namespace>/mcp |
POST | The fleet site; a Bearer <handle>.<key> live session admitted at this door's resource; a per-session, per-door rate limit |
wire() hooks four things: register_routes() on rest_api_init, the body
cap on rest_pre_dispatch, point_to_sign_in() on rest_post_dispatch, and
the door's resource() on
newspack_nodes/oauth_resources. The resource is
the route's REST URL over https, the host lowercased and no trailing slash.
Authentication. check_permission() takes a command session, minted by hand
or by an OAuth grant for this door's resource; a
grant session for another resource is refused. It signs in as the session's
minting user and installs the session's scope as the request's ceiling, so a
tool is listed and called only where Capabilities::can() admits the role its
command declares. It installs the session's label as Capabilities::$session_label,
which a command reads to name the caller: '' for a session minted without one.
Then, and only then, so an unauthenticated flood
spends no slots, it claims one of RATE_LIMIT_BURST (20) slots named
mcp:<namespace>:<handle> through Rate_Limit, an atomic add() on
Cache_Backend::shared_first() under a RATE_LIMIT_WINDOW_S (10 s) TTL, which
memcached frees 9 to 10 s after the claim and APCu 10 to 11 s.
| Status | code |
When |
|---|---|---|
| 401 | mcp_unauthorized |
No credential, a session unknown or expired, a wrong key, or a session issued for another resource; the response carries WWW-Authenticate: Bearer resource_metadata="<url>", which names where to sign in |
| 403 | newspack_nodes_not_fleet_site |
A multisite subsite |
| 429 | rate_limited |
The session spent this door's budget |
| 503 | rate_limit_unavailable |
Neither memcached nor APCu can meter the call |
Protocol. One POST carries JSON-RPC 2.0, MCP revision 2025-06-18:
initialize, tools/list and tools/call. A body to the door's own route
over Command_Interpreter_Node::MAX_BODY_BYTES (1048576 bytes) is refused on
rest_pre_dispatch, with a 413 carrying -32600 Request body exceeds 1048576 bytes. and a null id; one exactly at the cap is read. That filter runs before
WordPress decodes a JSON body, which it does while validating the request's
parameters, and before the permission check, so an oversized body answers 413
whether or not it carries a live session. A message with no id is a
notification and is answered with a bodiless 202; a body that is not JSON-RPC
2.0 answers -32600 with a null id, and an unknown method or a tool the session
may not call answers -32601. A tool's argument object rides to its command as
--name=value tokens, a list as one token a member, and a command's refusal comes
back as a tool result with isError true, in the words
Core::caller_message() lets the session read
(see Error words). Every reply leaves as JSON encoded
with JSON_HEX_TAG inside one <site-data> fence, so a payload cannot close
the tag, and initialize serves FENCE_NOTE, the sentence saying what the tag
means, ahead of the door's own instructions and its caveat. The server's
methods are final; a door declares, it does not override.
Deriving the tools. Mcp_Door::declared_tools( $node, $class, $prefix, $except = [], $text = [] ), final protected static, answers one tool per
command $class declares but the $except verbs, for the node mounted as
$node. A tool is named <prefix>_<verb>, summarized by the command's own
description and offers every arg the command declares, its text
{declared}, which tools/list fills with that arg's own description and
throws \LogicException for an arg declaring none; $text, keyed by verb, overrides a tool's name, summary or
args. An $except or $text entry naming a verb the class does not declare
throws \LogicException, so a door cannot drift from the commands it fronts.
Nodes_Mcp_Door answers at
POST /wp-json/newspack-nodes/v1/mcp, its serverInfo naming
newspack-nodes and the plugin's version. Its tools are declared_tools()
over each node in Bootstrap::SUBSTRATE_CIS, the table the substrate mounts
its service CIs from, with the hyphen in raw-logs written as
an underscore, so a command added to one of those classes is a tool with no
edit to the door. No compositions command is a tool, because a layout is page
furniture, not data. A session sees the tools its scope covers:
| Role | Tools |
|---|---|
| read (16) | status_get; workers_list, workers_dump_graph, workers_dump_cleanup, workers_inspect; topologies_dump, topologies_get, topologies_expand; raw_logs_list_logs, raw_logs_dump_log, raw_logs_read_message; classes_dump; settings_get; layouts_get; aggregator_summary, aggregator_list_servers |
| tune (2) | settings_set, layouts_save |
| manage (12) | workers_restart; topologies_save, topologies_delete, topologies_activate, topologies_deactivate; aggregator_probe; vault_list, vault_get, vault_delete, vault_test; sessions_list, sessions_revoke |
A read session therefore lists 16 tools, a tune session 18 and a manage
session all 30. manage reaches this door through an OAuth grant for it, or
through the unlabelled session a dashboard mints; a session issued by hand
holds at most tune. A manage grant mints no other session, and it carries
its approver's whole authority over the fleet: topologies_save and
topologies_activate run any graph, so a File_Tail can follow any file the
PHP user can read, wp-config.php and its salts included, into a log
raw_logs_dump_log reads back, and a Curl node can make requests to any
host the site reaches. Revoking the grant ends its sessions and does not
recall what it already read. The approval page checks the scope the client
asked for when the user can grant it, and tune otherwise
(ADR-36). declared_tools() leaves out every command declaring a
secret arg, and Nodes_Mcp_Door::NOT_TOOLS five more; calling one answers
-32601 Unknown tool: <name>:
| Command | Why it is not a tool |
|---|---|
sessions create |
It mints a bearer key |
vault add, vault update |
Each declares its password secret, which would pass through the agent's transcript |
topologies connect_worker_input, topologies mount_tables |
Each mounts nodes into the request's graph, and an MCP call is one request that ends with its reply |
workers heartbeat |
It refreshes a session's SSE slot, and an MCP session holds none |
raw-logs read_block |
It answers through the envelope's FROM, and a door hands it no envelope; dump_log and read_message read logs |
vault list and vault get answer through public_shape(), which drops the
password. The door's caveat is one clause, and closes every tool
description; the door's instructions name, once, the manage tools that change
the running fleet — restarting workers, saving, activating, deactivating and
deleting topologies, revoking sessions and deleting Vault spokes — so an agent
calls them only on the operator's own request.
POST /wp-json/newspack-nodes/v1/command
Unified non-streaming dispatch endpoint. The browser POSTs a TM_COMMAND
envelope; the controller routes it through the request-scope _router to the
named CI; the CI's reply walks back via TO=FROM through _output — an
HTTP_In_Node, a double-duty class that is BOTH the /command REST controller
and the egress Node registered as _output (Node_Names::OUTPUT) — whose
fill() writes the packed Message directly to the HTTP response body. _output
is the egress name inside an SSE stream process too, where an HTTP_Filter_Node
holds it. In the browser graph the two names split: _http is the HttpOutNode
that POSTs the batch, and _output is the local sink an unaddressed reply lands
on.
The permission callback (HTTP_In_Node::check_permission) authenticates the
request and does not sign any command inside it — see
Command Signing. A signed-in user is the FLOOR every command
behind the endpoint needs, because a command may declare a WordPress capability
any user may hold, not the level any of them demands; who may call what is drawn
under Service CIs.
The door routes a message only where the request graph meant it to go
(ADR-23).
A message whose TO's first segment names no node, an empty TO included, routes
on: the base interpreter bounces a TM_PING or TM_EOF, and the Router answers
the rest NOT_AVAILABLE. A request command may also go TO a bare name that holds
an interpreter. Any other message, and a command whose TO runs past its first
segment, may go only TO a node a gated mount built earlier in the same POST: a
worker's input Partition through topologies connect_worker_input, or a Table
through topologies mount_tables. A caller holding no role may send only a
request command whose TYPE is TM_COMMAND and nothing else, to an interpreter.
Each refused message is answered after the batch with a TM_RESPONSE|TM_ERROR
frame carrying its ID — the door routes a command to an interpreter, or a message to a node this POST mounted, or for a roleless caller a caller holding no role sends a bare command to an interpreter alone — without the 401
a signature failure raises.
The body is JSONL — one packed Message per line, where each line is the JSON
of the substrate's 7-slot positional array
[TYPE, TIMESTAMP, FROM, TO, ID, KEY, VALUE] (the wire form Message::packed()
emits). Multiple lines in one POST batch through the request-scope graph
serially, so an earlier command's side effect is visible to a later one — a
client sending connect_worker_input or mount_tables ahead of the command or
request it enables depends on that. Blank lines are skipped; every other line must decode to a 7-element
positional array, and Message::unpacked() throws on the first that does not. A
body carrying no parseable line throws as well.
That mount is not a pure graph edit.
Bootstrap::register_worker_partition() reads the id through
CLI::parse_worker_id(), which refuses any string
CLI::worker_id() cannot write — a / or NUL in the type, a leading zero in the
partition — then returns early when the partition is already mounted,
and otherwise resolves the worker through
Spawn_Coordinator::worker_channel(). A
sleeping on-demand worker, which holds no lock dir, skips the ipc/{id}/input
existence check, because it creates that directory only once it runs, and is
woken through Spawn_Coordinator::wake_channel() once its input is mounted — so
attaching a browser console to an idle on-demand slot STARTS a process. A mount
refusal is silent, since the command answers an empty string, and the command behind
it bounces NOT_AVAILABLE instead. A wake the spawn endpoint refuses is not: the
command answers a TM_COMMAND|TM_ERROR to its FROM whose payload reads
wake failed for {id}: HTTP {status}: {the endpoint's message}, or the transport's
error when the POST never landed, in the same response body, and the commands
behind it still land in the mounted input for when the worker starts. The endpoint's
message is cut to its first line and 200 bytes, tags read as spaces. A
429 spawn_throttled is another process starting that worker, and answers nothing;
any other 429 is a refusal like the rest.
topologies mount_tables <topology> also answers an empty string, but refuses
out loud: an inactive topology draws a TM_ERROR reading
mount_tables: <topology> is not active, and a Table whose backend cannot open
fails the command naming the Table, unmounting whatever the call had mounted. In
PHP, Bootstrap::mount_table() throws
Table_Unavailable for that backend,
and a plain \RuntimeException for a Table two active topologies declare
differently or a TTL that is not a whole number of at least 1. Each
mount is named {table}.p{N} and serves one read alone, GET
(ADR-23);
Other Node Primitives carries the
Table protocol. A Row_Table mounts nowhere: mount_tables leaves it out, because only its
worker answers ROWS, and a reader opens its files through Row_Table_Node::read( $schema, … ),
naming the schema, which Bootstrap::row_table() resolves to the one table declaring it,
an answer cached across processes for up to 300 seconds.
Row_Table_Node::build( $schema, $name, … ) resolves the table afresh through
Bootstrap::resolve_row_table(), as every path writing or deleting its files
does, attaches the same files read-only into a connection writing a build file
beside {base}/tables/<name>.sqlite, and renames it over
that snapshot when the build answers anything but false, for a job publishing a derived
file; Row_Table_Node::open_snapshot( $name ) reads the published one, immutable, and
remove_snapshot( $name ) deletes it with any build a crash left
(ADR-35).
Send that body as text/plain; charset=UTF-8 — the COMMAND_CONTENT_TYPE the
browser transport declares — and never as application/json; the diagram under
Response shows why.
Per-slot semantics (named here for documentation only — the wire is positional):
| Slot | Type | Description |
|---|---|---|
TYPE (index 0) |
int | Bitmask. TM_COMMAND (8) for a dispatch. |
TIMESTAMP (index 1) |
float | Unix timestamp. The signature covers it truncated to whole seconds, and the freshness window is checked against it. |
FROM (index 2) |
string | Reply path. HTTP_In_Node stamps _output onto it on the way in, so a bare reply path (_output, _sse:{session}/…, or empty) walks back to this endpoint. The _sse form is minted by the browser's RemoteIpcNode, which rewrites a non-empty reply-node FROM to _sse:{session}/{node} before sending, naming the handle of the page's command session — that is how the server's HTTP_Filter_Node passes an async reply to the stream this session holds, across reconnects, rather than to another tab's. |
TO (index 3) |
string | CI node name (e.g. topologies, workers). Router peels the head off; subpaths flow through. Empty TO is dispatched by the base CI in-place. |
ID (index 4) |
string | Caller-chosen correlation id. The CI's reply carries the same ID. |
KEY (index 5) |
string | Routing and correlation metadata (e.g. 'completion' triggers REPL completion-list mode on help and ls). |
VALUE (index 6) |
array | The inner Command_Interpreter envelope {name, arguments} as a live JSON array. name is the verb; arguments is a flat token array (list<string> argv) — the Shell and the browser transport tokenize ONCE at the producer boundary, and the tokens ride verbatim through envelope, interpreter and make_node. Every command, scalar and structured alike, reads its data from that array ($args[0], $args[1], …). VALUE also carries auth, the HMAC envelope every minter stamps; see Command Signing. |
The browser's command transport and the attached wp nodes cli both produce
this exact wire shape via Message::packed().
The CI's interpret() produced a reply, and HTTP_In_Node::fill() (the egress
side) sent 200 and wrote the packed Message to the body.
The status line alone, with an empty body: the batch routed without the
_output egress seeing a reply. Because Command_Auth decides signability
from the TYPE bit rather than by sniffing for name, a malformed inner
envelope signs cleanly and then vanishes into the struct drop, which runs
before verification.
The batch answers 401 when any command in it failed Command_Auth
verification; the 401 is the fast signal a client checks before parsing the
body. A capability refusal is
not this 401: Capabilities::require_command() throws
RuntimeException( "permission denied: <role> capability required" ) from
Command_Interpreter_Node::dispatch(), after authorize has already passed.
Sent as a packed positional Message, the same wire shape as the request. Example body:
[288, 1735689600.5, "_command", "<request from>", "<request id>", "", "request-scope graph not initialized (missing _router or _output)"]TYPE = 288 = TM_RESPONSE | TM_ERROR (256 | 32).
HTTP_In_Node::emit_error() sends it; operational application errors never
reach this path.
A batch turned away at the door never reaches a command, so the browser transport
answers its own caller rather than let a node wait out its deadline:
postBatch() mints the undelivered: true refusals the diagram shows.
The substrate mounts eleven service CIs through newspack_nodes/request_graph_ready
(newspack_nodes_mount_substrate_cis() in newspack-nodes.php), one per
entry in Bootstrap::SUBSTRATE_CIS, the table of mount name to class that
the substrate's MCP door reads too. Each is a
Service_CI_Node declaring its commands once in node_schema(), and the base
derives the dispatch table from that declaration, while dispatch() refuses each command
below the role it declares (ADR-26). A
command that declares no capability demands MANAGE, so silence is the strictest
role rather than the loosest.
| Node name | Class | Commands (role) |
|---|---|---|
classes |
Classes_CI_Node |
dump (read) |
layouts |
Layouts_CI_Node |
get (read), save (tune) |
compositions |
Compositions_CI_Node |
get (read), save, restore (tune) |
topologies |
Topologies_CI_Node |
dump (read), get (read), expand (read), save, delete, activate, deactivate, connect_worker_input, mount_tables (manage) |
raw-logs |
Raw_Logs_CI_Node |
list_logs, dump_log, read_message, read_block (read; a log carrying a read-capability marker also needs its capability, ADR-39) |
vault |
Vault_CI_Node |
list, get, add, update, delete, test (manage) |
aggregator |
Aggregator_CI_Node |
summary (read), list_servers (read), probe (manage — on-demand per-spoke deep roll-up) |
settings |
Settings_CI_Node |
get (read), set (tune) |
status |
Status_CI_Node |
get (read) |
sessions |
Sessions_CI_Node |
list, create, revoke (manage — issuing one hands out access) |
workers |
Workers_CI_Node |
list, dump_graph, dump_cleanup, heartbeat, inspect (read), restart (manage) |
The first column is the NODE name — make_node's second argument, and what a
caller puts in TO. A CI's SHELL name is a different string: the class short name
minus _Node, so Layouts_CI_Node is addressed as layouts and described as
Layouts_CI. Command_Interpreter_Node::shell_name_for() derives that name;
the classes CI's dump command reports it under shell_name, dump_metadata
returns it as class, and the topology console's Inspector looks a node's commands
up by it. It is also what help Layouts_CI renders a schema for, because
Bootstrap registers the Newspack_Nodes\Rest\ prefix alongside
Newspack_Nodes\.
workers inspect runs one read command inside a live worker.
inspect <worker> <verb> [<arg>…] [--path=<node>] sends the command named by
verb and its args into the worker <type>.p<n> and answers that command's
reply. The command declares its args as worker, verb, args and path,
in that order, so the variadic args takes every positional token after the
verb and path is given only as --path=<node>. verb must have a READ base
role, by Command_Interpreter_Node::base_role( $verb ), the rule dispatch()
reads for a command every schema leaves undeclared (ls, dump_node, stats, dmesg
and the rest); path must be empty, the worker's own interpreter, or name a
<node>:config interpreter, a path holding no /. Any other verb, a different
case or a trailing space included, and any other path are refused before a
channel opens, so a read session reaches neither a worker's mutating commands
nor a node that would store the command rather than interpret it. The command
is signed with the site key for that one call
(ADR-23,
ADR-15).
An unknown worker is refused, and a sleeping on-demand worker is woken as
wp nodes cli wakes it.
The round trip is the CLI instance's ask_workers( $receiver, $asks, $timeout_s ), the one wp nodes tables makes, bounded by
Workers_CI_Node::INSPECT_TIMEOUT_S (5 s). Each call mints its own reply
session, _cli:<pid>-<suffix> with a random suffix, because in php-fpm one pid
serves many requests and two calls under one head would read each other's
replies. It reuses a worker's input Partition the request already registered,
as topologies connect_worker_input registers one, takes _router and
_command_interpreter through the get-or-build helper
Bootstrap::mount_request_graph() uses, records each node it builds as it
builds it, and removes those alone in a finally, whatever the outcome. A
worker silent for 5 s is refused by name, and because a command whose answer
is empty sends no reply, the refusal says so. A worker's own refusal comes
back as the command's error, read by CLI::refusal(), which answers null for a
reply that is not a TM_ERROR.
vault add and update take a group. A server's optional group
option, held to the same id rule as its own id, groups it with any other
server sharing the name; public_shape() echoes it back on list and get
alongside url, auth_username, auth_header, auth_scheme and
has_credentials. It is the field a
Vault_Group node reads to decide its members.
vault add and update take a header and a scheme. An entry with a
password, no username and a header sends <header>: <scheme> <password>,
or <header>: <password> with no scheme, where an entry with a username and
a password sends Authorization: Basic, and any other entry sends nothing.
Vault::credential_header() is that one rule for
every transport. The header is empty for none, or 1 to 64 letters, digits and hyphens, and never
one of Vault::FRAMING_HEADERS (Host, Cookie, Content-Length,
Transfer-Encoding, Connection, Expect, Upgrade, TE, Keep-Alive,
Proxy-Connection, Proxy-Authorization) or Content-Type, in any case; the scheme is empty or 1 to 32 of the same. Each
command refuses either by name, invalid header: <header> or invalid scheme: <scheme>, and an absent option leaves the stored one alone on update.
has_credentials is true when the entry sends a line.
workers.dump_graph vs dump_metadata — different commands, different shapes.
The workers CI's dump_graph returns the dashboard payload:
{ workers[], unreadable, consumers[], unparseable_lines, refused_producers,
logs[], log_partitions, deadletter_segments, deadletter_by_reader,
num_partitions, max_segments, segment_size, timestamp, heartbeat_interval_s,
graph }
unreadable maps each active topology that will not read to its message, and
graph omits it. refused_producers maps each registered log producer
template that declares no dir under <config:logs_dir> to its message, and
logs[] omits its dirs. Both are [] when nothing failed, and the Overview
tab renders each entry as an error banner.
consumers[] is a report, not an inventory: CLI::consumer_rows()
builds it from one Probe_Record per
reader in the shared topicprobe log, so a reader that has not reported recently
drops out instead of reading stale, and a record aged past
Topic_Probe_Node::stale_after_s() is
re-measured off disk. Every worker appends to that one log, and a short write
can leave a torn line there; unparseable_lines counts the lines in the window
that would not unpack and were skipped, and wp nodes status warns with the
same count. Alerts::evaluate() reads
the same snapshot, so an alert can never name a fleet the dashboard does not
show.
The same log carries one Partition record per log a worker covers, its READER
blank and its SOURCE the log's SSE stamp (ingest.p0, offsets:ingest.p0),
or its path under the runtime base outside those roots, holding the log's byte length (END_BYTES) and the disk it takes
(END_DISK_BYTES). consumers[] keys by READER and skips them; the
Overview's Partition Size and On Disk panels read them off the stream.
workers dump_cleanup names candidates, not casualties. It answers
{ logs_dir, on_disk_basenames, expected_basenames, orphans } — every
first-level dir under logs/, the set Log_Cleaner::declared_log_dirs()
retains, and the difference between them. Both halves are the calls the sweep
itself makes, but the diagnostic applies NO grace window, where the sweep and
wp nodes gc spare any dir whose newest inner mtime is under
Log_Cleaner::DELETE_GRACE_S (3600s). A dir an in-flight deploy created moments
ago therefore lists as an orphan here while gc would still leave it alone, so
reading this command as "what the next sweep deletes" overcounts.
sessions list — the labelled sessions and the OAuth connections, never a
key or a token. It returns { sessions[], connections[], ttl_max, scopes{} },
each session { handle, label, scope, created, expires } and each connection
{ grant, name, client, scope, created, expires }: the grant id, the name its
approver gave it, the name its client registered (empty once that registration
has lapsed), the approved scope, when it was approved and when it expires,
thirty days on. Oauth_Store::grants()
lists a connection whether or not it holds a live session, reading the
oauth:grants set, then every grant it names in one read, then their clients
in one more; an ended grant is left out. ttl_max is Command_Auth::SESSION_TTL_MAX_S and
scopes maps each of Capabilities::LABELLED_SCOPES, read and tune, to
Capabilities::label(), the words the OAuth approval page shows for it, sent so
a client draws its TTL bound, scope picker and scope descriptions from the
substrate rather than a second copy that drifts into offering a scope the mint
refuses. Every row is live:
Sessions::listing() reads the session Table's
labelled set, then every row it names in one read, takes each fact from the
session's row, and leaves out a session whose row has gone — lapsed, a
sessions revoke, or wp nodes session revoke --all. A store that does
not answer, or a set holding more than 10,000 handles, refuses the command rather
than listing part or nothing. Reading the listing writes nothing.
sessions create and sessions revoke. create [<label>] [<scope>] [<ttl>]
binds each arg by position or by name, so create chris-claude tune 86400 and
create chris-claude --scope=tune --ttl=86400 mint the same session; scope
defaults to tune, refuses manage (only Capabilities::LABELLED_SCOPES scopes) and is clamped to what the issuing user holds, and ttl to
Command_Auth::SESSION_TTL_MIN_S–SESSION_TTL_MAX_S. revoke <id> takes a
session's handle or a connection's grant id and answers { id, revoked: true }
only when it dropped a session's row or ended a grant. Revoking a connection
ends its grant, so it refreshes no more, and every session it minted, as
revoking one of those sessions does. A store that did not answer refuses
session store did not answer; <id> may still be live and leaves the listing
as it was. An id naming neither refuses no session or connection with id <id>, and when <id> is a session's LABEL — what an operator reading the
Sessions tab types — the refusal names the handles carrying it: no session or connection with id <id>; the label <id> names <handle>, ….
topologies expand and topologies get compose different views. expand
returns Topology_Analyzer::expand()'s shape for an include SET —
{ nodes, edges, tree, hulls }. Both commands take --group_children=false, which leaves each
Vault_Group as written: no derived member, in nodes, edges or hulls. The topology
console's editor seeds from that form; every other reader gets the members. Either form draws
the group's own pair edges, the ones its child class declares, on the group. A node
carries name, class, fans_out, args, commands, origin and via — each of
commands a { verb, args } invocation the topology aims at the node; an edge carries
from, to, origin, roles — connect, config, pair, or several — and, where a
config role exists, config_slots naming the setter verbs that made it. A pair edge runs
from a Remote_Source, or a Vault_Group of them, to the target one of its
add_source lines name, which the broker writes through that source's reader; no
connect_node or disconnect_node adds or removes one. origin is a LIST because a diamond
include is provided by several directly-declared includes, while via is only the first path
a node entered through. hulls maps every topology in
the tree, at any depth, to the node names it declares, depth-first so an outer
topology precedes what it brings and the canvas paints the nested hull on top.
The whole payload is informational — the runtime is the Shell's include — and
it is the topology console's edit-mode baseline.
topologies get <name> returns { name, source, tsl, includes, expanded, resolved_config_edges }, and its expanded is that same shape built from the
file's DIRECT includes alone: it holds the borrowed members and none of the
file's own lines, which is what lets the editor render a borrowed node as
borrowed. --group_children governs expanded alone: resolved_config_edges
comes from the member-bearing walk. resolved_config_edges covers the whole topology, because a
config command pointed at a <namespace:key> token names an edge only the server
can resolve. A client parsing a document that carries such a token must treat a
response without that list as fatal rather than wire an edge to the literal
token text; the console's document loader throws there, and
augmentWithVirtualEdges() does not draw any edge at all for a token the server resolved
to nothing. A node an owner builds for itself, such as a Crawler's
{name}:seen Table, is not in any reply: a canvas never draws one, and it opens from
the "…" on its owner's card.
aggregator — one id field, two meanings. list_servers returns one
row per wired Remote_Source, keyed by the NODE name in id and carrying the
Vault credential id separately as vault_id, the topology it is wired in,
its partitions, each worker partition's status snapshot as the broker
publishes it through Publishes_Status
under status:<broker>:p<partition> and empty until the broker publishes one,
and its readers: one { stamp, partition, cursor, distance }
per stream reader the probe log reports, stamp as the spoke names the log,
partition the hub worker's, cursor in the read-position grammar (a
segment the reader does not know is :<offset>), and distance the bytes the
spoke last reported past its own reader plus what the hub reader holds
undrained, null until the spoke reports. list_servers alone reads the probe
log; summary counts connections and never decodes it. probe <id> wants the VAULT id,
answers server not found: <id> for anything else, and echoes it back as its
own id. A client that carries id straight from one command into the other is
refused. The probe's reply is the whitelisted roll-up
{ id, workers: { total, live, stale, held, idle, down }, worst_distance, deadletter_segments }, counting the spoke's workers by the state each
workers[] row carries — the word wp nodes status prints for that slot — so
a spoke reporting any other state fails the probe naming the worker, and
worst_distance is the largest known distance across the spoke's consumers[],
null when every reader's distance is unmeasurable. summary reduces the same snapshot
to { connected, idle, total, server_now, unreadable } — unreadable maps each
active topology that will not read to its message, and the Aggregator Status
tab renders each as an error banner — and its per-server reading is best
partition wins: connected while any partition is connected, idle while every
partition is disconnected but one carries a scheduled_reconnect_at, and down otherwise —
so a partition mid-handshake or in error backoff counts toward down.
settings — get and set do not cover the same keys. get answers the
seven storage settings alone — num_partitions, segment_size, min_segments,
num_segments, min_lifetime, lifetime, max_segments — as EFFECTIVE values
through Config::value(), so a key never saved reports its schema default
rather than an empty option. set reaches every int Field declaring a
minimum: those seven plus the six remote_* spoke-geometry keys, the three
alert_* thresholds and the four bounded sse_* limits, whether or not the
settings page renders them. An int Field with no minimum (sse_idle_timeout,
sse_max_lifetime, sse_retry_ms, on_demand_idle) and every Field of another type
(base_directory, memcache_servers, log_sources, vault) is refused as
unknown setting: <name>. The option name is accepted either way it is spelled,
short key or full newspack_nodes_ option name, which is how
Settings_Sync_Node pushing the full name and an operator typing the short key
drive one command unmodified.
Two set behaviours differ from the settings page. A value outside the Field's
bounds is REFUSED rather than clamped, throwing invalid value for setting: <key> as a TM_COMMAND|TM_ERROR reply. And a value already in place writes
nothing, and neither resets the config nor requests a restart, which is what keeps the sync
sweep's unchanged re-pushes from recycling the fleet every tick; a real change
resets Config and asks Restart_Planner to recycle the topologies the Field's
restart class names. The reply to set is the same seven-key snapshot get
returns, so a caller that writes sse_max_streams or remote_segment_size
never sees its own value come back — that reply is not evidence the write was
ignored.
status get is a cheap probe. The snapshot is { status, runtime_version, num_partitions, topologies, cache_available, timestamp }. Its num_partitions
comes from Bootstrap::global_num_partitions(), the count clamped into
[1, Spawn_Coordinator::MAX_PARTITIONS] that the fleet spawns, while
dump_graph's same-named field is the raw config value cast to int — a
misconfigured value makes the two disagree, and only this one names what runs.
cache_available asks whether a backend is SELECTABLE: a non-empty memcached
server list reached Core::$memd, or APCu is loaded and enabled. Adding a
server connects to nothing, so a configured but unreachable memcached still
reports true; the add/read/delete round trip that catches it is
/health/runtime and the cache-backend row of
wp nodes doctor.
layouts backs one canvas. The Topology Console is its only caller, one
layout per topology name. It seeds useCanvasLayout's position map from
layouts get on load, for an edit-mode canvas or a worker scope whose label
matches the topology, and writes back only when a person clicks Save Layout —
dragging autosaves nothing. A layout's positions is a node-id map or the
string "auto", which the canvas treats as a map holding a fresh autolayout of
the graph it draws. "auto" is never stored: get answers it where the file is
missing or unreadable or its map places no node, and save given it,
or a map placing no node, removes the file, as Save Layout does while every card
still sits where the canvas laid it out. save refuses any other value. Every other canvas in the substrate, the debug
overlay's Inspector and each station tab graph included, persists positions to
localStorage through the same hook and never reaches the server, so a
dashboard wanting layout to survive a change of browser wires these two commands up
itself.
compositions stores block layouts. A consumer declares the blocks and
the contexts it composes on the
newspack_nodes/compositions filter, and a
layout is serialized block markup holding only those blocks' delimiters and
their JSON attributes. Each context keeps one option,
newspack_nodes_composition:<context>, not autoloaded, holding
{ markup, actor, ts } or, after restore, the tombstone
{ removed: true, actor, ts }. Every command answers
Composition_Store::read()'s
shape, { context, source, blocks, saved_ts, actor }: source is saved or
default, blocks a tree of { name, attributes, inner } with each block's
attributes laid over their declared defaults, and saved_ts and actor say
when the record was written and by whom, null for a context with no record.
Composition::validate() runs on every
read, the default included, and on every save, so a stored layout a later
version no longer reads is refused rather than replaced by the default. It
refuses, naming the block and the field, markup past MAX_MARKUP_BYTES
(65536), a block the context does not admit (its own blocks, the shared blocks
it admits and the core blocks every context admits, as
Composition::blocks_for( $context ) answers them), a block deeper than
three, a list item excepted, since it is part of its list,
HTML anywhere but a core block's own element, a block holding blocks that is
not a container, a container anywhere but the top, a core/column or a
core/group, a core/column outside core/columns, a core/list-item
outside core/list, anything else in either, a second multiple: false
block, an undeclared attribute or one its schema refuses, more blocks than the
context's max_blocks, and anything the context's check refuses. The core
blocks are core/heading (level 1 to 6), core/paragraph, core/list
(ordered, reversed, start and type, a numbering style),
core/list-item, core/group (tagName and layout, the keys core
writes), core/columns (isStackedOnMobile, verticalAlignment and
layout), core/column (verticalAlignment, width, a CSS length or
percentage, and layout), core/separator (tagName, hr or div) and
core/spacer (height and width, CSS lengths, and style.layout's
flexSize and selfStretch); core/paragraph keeps direction. The text of a
heading, a paragraph or a list item answers as its content
attribute, filtered by wp_kses() to strong, em, b, i, code, br
and a with an href, a target and a rel on every read, and refused
whole when it holds a comment, which the filter could rebuild into a block
delimiter; content in a block's delimiter is refused. The namespace core
belongs to WordPress, and a declaration under it is refused. save
stores the whole tree, every attribute written, with no whitespace between
delimiters, a text block's text in its text element and every other core
block with no element, the shape the editor writes, holding the escaped
result to the same cap and refusing a tree that does not read back as
itself; each attribute
and each declared default takes its schema's type through
rest_sanitize_value_from_schema(); and actor is the caller's login, else
Capabilities::$session_label. Composition_Store::seed() writes only a
context that has no record, after every rule but check, so neither a saved
layout nor a restore is ever reset. A refusal can name a key the reader may
not see, so read() throws it as worded only to a caller who
Core::speaks_for_operator() — a logged-in manage user, or a worker's
command under a manage session — and throws this layout could not be read
to everyone else, the compositions get command included.
Composition_Store::page_data( $contexts ) answers each context's read(),
or { context, error } for one refused, error the refusal as
Core::caller_message() words it for this caller, and enqueues core's
wp-block-library styles, which the core blocks wear.
Composition::editor( $context, $handle )
mounts the editor for a tune caller alone: it runs
Block_Editor_Bootstrap::load_canvas( $context ) and
Block_Editor_Bootstrap::load_content_blocks(), which registers the core
blocks the canvas offers, enqueues the consumer's
registered bundle $handle and localizes Composition::schemas( $context ) on
it as NewspackNodesComposition; for any other caller it does none of it and
answers false.
Composition::enqueue_editor( $pages, $handle, $build_dir, $build_url )
loads the editor of the page $_GET['edit'] names, $pages mapping each
page slug to its context. A slug $pages does not list, or a
build dir without layout-editor.js, answers false and loads nothing;
otherwise it registers $handle from layout-editor.js with the
dependencies and version of layout-editor.asset.php, calls editor(), and
when that loads, enqueues layout-editor.css where the build holds one. It
answers what editor() answered.
The browser half lives in @newspack-nodes/shared/composition/, split so a
reader's page loads no editor script. The reader half imports neither
@wordpress/block-editor nor @wordpress/blocks: <CompositionView blocks views renderError /> renders a tree by each block's registered View, every
node inside its own error boundary, a container's View handed its children;
useComposition( group, context, onWritten ) reads the context fresh and
answers { answer, error, loading, save( markup ), restore(), refusal, writing }; and <CompositionEditSlot context … /> renders whatever editor
the context registered through registerCompositionEditor( context, Component ), a registry on the __newspackNodesCompositionEditors window
key, and nothing before. The editor half, which a consumer builds as the
second entry Composition::editor() enqueues, is registerCompositionBlocks( schemas, views ), registering each localized schema as a dynamic block whose
edit is CompositionBlockEdit, which hands the block's Controls its
attribute schemas as schema, and <CompositionEditor group context heading schemas preview onDone onDirty? />, the canvas over a fresh get, which saves
serializeLayout( blocks, schemas ), each block's declared attributes alone
and a core block's text in the tag its schema's text names, and calls onDone( answer, verb ): save or
restore after a write, null after Cancel or Back. onDirty( dirty ) is told
whether the layout holds a change not yet saved, as that turns, so a page asks
before leaving only once there is one. The page shows the
"Layout saved." snackbar, because the editor is gone by then. Given
patterns, the list schemas() localizes, and at least one in it, the
editor's header offers Start from: a chosen pattern's blocks, built from
the tree the server validated, replace the canvas unsaved, after the discard
confirmation when a change is pending. The layout counts as changed only while
serializeLayout() writes it otherwise than the layout read, an attribute
holding its declared default left out because the server lays it back, so a
canvas that writes back what the layout held leaves it clean.
The substrate declares six shared blocks, through
Composition::declare_substrate_blocks() at priority 5: newspack-nodes/stats,
time-chart, table, log-stream, sparkline, and section, a container.
Each of the first five reads the source its source attribute names, and a
consumer admits whichever it wants on a context and spreads SUBSTRATE_VIEWS,
from @newspack-nodes/shared/blocks, into the views it hands the frame. The
frame renders the core blocks itself, in core's elements and classes, so a
consumer declares no view for them.
The page frame in @newspack-nodes/shared/composition/page carries both
halves. Its reader half, which page/index.js exports, includes
providedViews( views ), each View wrapped to provide its editing prop as
the Editing context nested write controls read, which ComposedLayout
applies to the views it is given. Its editor half is
page/mountComposedEditor, imported by path:
mountComposedEditor( localized, views, { heading, wrap?, preview? } ) takes
the NewspackNodesComposition object, registers its blocks against the
provided views, leaving the core blocks to core's own registration and
confining the canvas to what a layout keeps (confineCanvas(): core's blocks
lose the supports and styles whose attributes a save drops, and every format
outside schemas()'s inline elements is unregistered, while
CompositionEditor hands the provider CANVAS_FEATURES, every colour,
typography, spacing, border and dimension setting off, and refuses an
indented list item),
throwing on a localized block that has no view, and registers an editor that renders
CompositionEditor inside wrap( children, slotProps ), sending for the
group of the ComposedPage it reads there, with
<preview { ...slotProps } /> beside the buttons.
Every Command_Interpreter_Node separately exposes dump_metadata for the
per-node canvas snapshot the topology console renders, keyed by node name:
{ class, counter, sink, target, targets, debug_state, arguments, lgst_msg,
bytes_read, bytes_written, accepts_fill, has_target, has_config }
target is the ROUTING value, Node::target() verbatim, a scalar unless the
node fans out; targets is the DISPLAY union Node::display_targets() returns,
always a list, the routing target plus any destination the node declares through
extra_targets()
(ADR-19).
accepts_fill and has_target come from the node's node_schema() and tell the
canvas which ports to draw. has_target: false is also enforced:
Node::refuse_target_on() refuses a target set on such a class,
through target() or connect_node, a Vault_Group refuses one its child class would
refuse, and Topology_Analyzer refuses a topology carrying one. A node another node published adds owner, the
publisher's name, and the console offers no delete or rename for it, because
remove_node and move_node refuse it. A node with registered listeners adds
registrations, and a node's own dump_metadata() may add further keys —
never clobbering a fixed one.
A full snapshot (no node named) leaves out every
Node::owned() node — one with a patron —
before building its schema, then every schema flagged hidden; the browser
producer dumpMetadataPayload() applies the same two rules, a browser node
having a patron and no publisher. An owner's row lists its owned siblings
under sidecars, [ { name, class } ] sorted by code unit, from
Node::sidecars(): every published sibling with
a patron but its :config interpreter and any schema-hidden one, so a
Crawler lists its {name}:curl and its {name}:seen Table, and a
Remote_Source its per-stream Remote_Consumer readers and its channel. The
key is absent from a row owning none. A published sibling with no patron,
such as a Vault_Group's child, draws on the canvas instead. A patron keeps
a node's :config, so a sidecar's commands reach it there as a canvas node's do.
Naming a node, dump_metadata <name>, answers it unless its schema is
hidden, owned or not, carrying owner as above. dump_metadata -o <name>... answers each node named the same way, then the row of every
sidecar it lists, so a client asks for an owner's sidecars by the owner's name
however many it owns: that is how the console polls every grid of live cards
an owner's "…" opens, while it is open, and the sidecar its Inspector shows,
Time Travel and Triage included, in one command. A full snapshot also carries a
_header entry holding profiling and, when the command supplied one,
pwd.
registrations comes from Node::registered_listeners(), which walks the
registration table and keeps only the entries whose callback is null — the
Node-NAME listeners. A closure listener has no name to draw an edge to, so it is
skipped, and an event left with no name listeners drops out of the map rather
than reporting empty. Timer_Node, which registers itself by name on the
router's TIMER channel, therefore shows a registration edge, while
Remote_Broker_Node, which registers a closure on the fleet's RELOAD, shows
none: a missing edge is not evidence that nothing is listening.
A composite node reports its PATRONS' I/O, not its own. Once
a Remote_Source_Node's patrons exist, counter is SSE_In's frame count,
bytes_read and lgst_msg come from SSE_In, bytes_written from HTTP_Out, and
the link's own relay increments are masked — so a rate or throughput figure read
off a Remote_Source row measures the wire, not the node. Before
the patrons are built, on a fresh drop or a spoke with no Vault entry, both byte
counts read 0 and counter falls back to the node's own tally.
Address dump_graph to workers for the dashboard shape; address
dump_metadata with empty TO for the canvas shape.
The root (empty-TO) base Command_Interpreter_Node's own vocabulary, the
Shell builtins that precede it and the secure ladder are in the command chart
above. help <NodeType> renders a class's node_schema() / nodeSchema()
identically in PHP and browser-local JS. Addressing a command with empty TO
dispatches against the root table; a non-empty TO routes to the named CI.
node_schema() shape. A CI's node_schema() returns a Service-category
schema: { category, description, arguments, commands }, where each commands
entry is { name, description, capability, args, handler } plus the optional
console flags multiple, hidden and action. A command with none is configuration the
editor writes into a .tsl and also a command a live node takes; multiple makes it one row
per call, as every add_* is; action keeps it out of the editor and in the live panel
and the REPL, as every remove_* is; hidden drops its button from the Inspector, though
the REPL and the Inspector's own controls still send it. Each arg is
{ name, type, required } plus an optional default (for example,
workers restart's partition, defaulting to -1) and an optional secret.
A node schema may also
carry requests, registrations, accepts_fill, has_target, owns and
hidden; owns names the siblings a class builds that the analyzer must know
of, suffix to class, such as a Crawler's seen Table, and only a class on the
palette may declare it. A REST reply never carries it. hidden keeps a class off every console surface: the
palette, the canvas, a sidecars list and the class catalog.
A requests entry is { name, description, reply_shape } plus an optional
args, in the shape a command's take, and an optional
handler( static $node ): array; classes dump carries every field but the
handler, and nothing undeclared. It contributes no dispatch entry: the
addressed node answers a TM_REQUEST itself, by calling
Schema_Reflection::answer_request( $message ) first in its fill() and
returning when that answers true. The trait takes VALUE's first word,
upper-cased, as the verb and runs the matching entry's handler for the
reply's data, answering TM_STRUCT | TM_RESPONSE with VALUE
{ verb, data }. Any other request, a handlerless entry included, is refused with
TM_ERROR and VALUE "unknown request verb: <VERB>\n", the error plane the
interpreter refuses a command on. Either reply goes from the node TO the
request's FROM, with ID and KEY echoed, and a request reaching a node with no
sink throws fill requires a wired sink. reply_shape documents data.
An arg's declaration is enforced. Command_Interpreter_Node::dispatch() binds a
command's tokens against its args before the handler runs
(ADR-25):
a missing required arg, an unknown --option, a surplus positional, a name given
twice or both ways, and a token not of its declared int or float type each answer
the command's TM_ERROR, and an absent arg takes its default. One arg may declare
variadic, collecting the positional tail, or every --name= repeat, as a list
of typed members. A bool takes 1, true, yes, on, 0, false, no or
off and refuses any other word, and a blank for a required arg is missing. A Node constructor's arguments()
is bound the same way by parse_schema_args()
(ADR-11),
except that a constructor's variadic argument must be last and is never
bound: the node reads the tail itself.
An arg declaring 'secret' => true is a credential. It is named, never
positional — the binder refuses <name> must be named: write --<name>=<value>,
echoing nothing, because the browser masks only --name= tokens in its history
and transcript — and the command line Command_Interpreter_Node::$around_dispatch
hands a wrapper renders it --<name>=<redacted>; every other token reaches the
wrapper verbatim. Core::$around_span is the same kind of slot for substrate
work outside a command: Core::span( $label, $work, $describe ) runs $work
through it, handing it the label, the work and a $describe that turns the
work's result into the span's closing detail, and runs $work bare when it is
null. A Table's purge pass (table purge) and WAL checkpoint (table checkpoint) go through it. An assigner composes with what is already there, as
with $around_dispatch, and the slot survives Core::reset(). vault add and vault update declare it on
password, the only secret arguments in the system: the Vault is the one place
a credential enters, and Vault::url_carries_credentials() refuses a url
carrying userinfo — judged after esc_url_raw(), and refusing one that will
not then parse — so none rides in beside it.
What help renders of that is thinner. The COMMANDS and REQUESTS tables
Node_Schema_Help::render() builds carry name and description alone: a
command's capability, its declared args and the multiple / hidden / action
flags never appear, and a hidden command prints like any other. So help Workers_CI will not tell an operator what restart takes or which of the three
roles it needs. The live-mode Inspector's command modals and the Service-CI tables
above are where arguments and capabilities surface; help is a summary, not the
command reference.
Classes_CI's dump command inlines the serializable half of every concrete Node
class's schema for the topology-editor palette and the live-mode Inspector:
each class declaring a category and no hidden flag. A Hidden-category
class, such as a broker's Remote_Consumer, is listed for the Inspector, which
reads a sidecar's arguments and commands here, and the palette leaves it out. It
returns { classes[], formatters[] }, each class carrying shell_name, fqcn,
category, description, arguments, commands, requests,
registrations, accepts_fill, has_target, is_interpreter and
fans_out.
The non-serializable handler — a command's and a request's — and the
server-enforced capability are stripped on the way out. formatters[] is Formatters::list_names() sorted, and it is
the option list the console renders for any node_schema() argument declared
type: formatter_name — today only the one with_index takes. The substrate registers no
formatters of its own, so on an install carrying no consumer plugin that calls
Formatters::register() the array is empty, the console falls back to a
free-text input, and a with_index command raises unknown formatter: <name> for
whatever is typed into it. Discovery reads the composer classmap
(ADR-10),
so a class added or renamed without composer dump-autoload -o is absent from
the palette.
Every command reads its bound arguments by name. A producer sends the flat
arguments token array — each arg by position in declared order or as
--name=value — and the handler receives
( Command_Interpreter_Node $interpreter, array $args, array $envelope = [] ),
where $args is array<string,mixed>, one key per declared arg: the typed value,
null for an absent optional, or a list of typed members for a variadic one. So command_node topologies get Home and command_node topologies get --name=Home reach cmd_get()
alike, as [ 'name' => 'Home' ]. raw-logs dump_log answers
{ log_id, segments: [ { id, size } ], segment_count, total_size } for the one
partition dir or sources:<name> registry source it inspects: a segmented
source answers its segments and their summed total_size, and a file source
one segment, { id: <inode>, size: <bytes> }, the slot its stream's ID
breadcrumbs carry, or none while the file is absent or unreadable.
An unknown or empty log is refused with an error.
raw-logs read_message binds the log key, then the
position — the single-step grammar <segment>:<offset>[:<length>], the
segment-less :<offset> a file source's stream states before it has seen the
file's inode, or one of start, recent and end, never the positions JSON
the SSE route takes; the two vocabularies share those three words and nothing
else. A segment-less position reads at that offset in the file the path holds
now, and a partition dir refuses it as a malformed position. It reads a partition
dir or a sources:<name> registry source, and an unknown or empty log is
refused with an error. See
Log Sources for the read model and the struct it answers.
raw-logs read_block binds <log> <position> [--multi_writer=<bool>]: the log
and position as read_message takes them, and a bool defaulting to false that
holds a segment boundary until Consumer_Node::SEAL_GRACE_SECONDS have passed
since the later of the older segment's mtime plus one second and the newer
segment's first record TIMESTAMP, and while the newer segment is empty; a
segmented Log holds bare values with no stamp, so it seals on the mtime alone.
It does not answer with a command reply: the reader's raw block goes to the
asker's FROM as two messages, its header echoing the position as ASKED <position> and its TM_BATCH sent even when empty, so the asker settles on it. The
ownership-fenced workers heartbeat binds exactly [ slot, owner ], both declared
int and read through Core::canonical_decimal(), from the current SSE connected
handshake, and the server — never the client — owns the lease TTL. topologies save
and layouts save bind [ name, body ], where the whole TSL body or positions JSON,
newlines included, rides as one token, with no rest-of-line splitting to guess at.
workers restart binds <type>… [--partition=<n>]: types is variadic, so a
partition is reachable by name alone, and an absent partition restarts every
one. A command whose schema carries no args key —
the base interpreter's own vocabulary — receives its raw token list. The
$envelope is the full 7-field positional Message; both save commands use it to
enforce a 1 MiB body cap via Message::packed_size( $envelope ).
KEY='completion' mode. A help or ls command carrying KEY='completion'
returns a bare newline-separated candidate list — sorted verb names, or bare
node names across the whole registry — instead of the tabulated output. The KEY
stands in for Tachikoma's TM_COMPLETION type flag, which this runtime does not
carry, and REPL tab-completion is built on it. See
architecture-guide.md → REPL.
Per-command args, return shapes and error semantics are declared on each CI's
node_schema() under
includes/rest/class-{classes,layouts,topologies,raw-logs,workers,vault,aggregator,settings,status,sessions}-ci-node.php;
the palette and the Inspector consume the same schema. Auth gating is uniform:
the endpoint requires a signed-in user AND a valid command signature, and each
command's declared role decides the rest. A command may declare a WordPress
capability in place of a role, 'capability' => 'wp:edit_posts': the signed-in
user's own current_user_can( 'edit_posts' ) decides it, neither the session's
ceiling nor the allowed_users list narrowing it. It runs only under an
unlabelled session — a dashboard's none, or a role holder's — and is refused
permission denied: edit_posts refuses a labelled session under a labelled one,
read and write alike, permission denied: edit_posts requires a verified command
under the ceiling a refused verification leaves, and
permission denied: edit_posts requires a signed-in user in a worker, so such a
command belongs on a CI the request graph mounts. No MCP door lists one
(ADR-26). A refusal THROWS —
Command_Interpreter_Node::interpret() wraps it as TM_COMMAND|TM_ERROR.
Error words. The reply's text is
Core::caller_message( $e, <command> ). A
deliberate refusal, an \Exception, reads as thrown to every caller. An engine
\Error (a TypeError, ValueError, ArgumentCountError, DivisionByZeroError, …)
or a driver's exception (PDOException, mysqli_sql_exception), anywhere down
the getPrevious() chain or among a Failures' members, can name files, lines
and database paths, so its own words reach only a caller who may run a MANAGE
command (Capabilities::command_holds()): a logged-in user holding manage
under the session scope in force, or a worker's command whose session allows
it. Every other caller, a manage user under a read or tune session and an
anonymous request outside a worker included, reads
<command>: internal error; the reason is in the server's log, and the class,
message, file and line of each link go to stderr — a worker's log, or PHP's
error log in a request — once per command while print_less_often()'s window
holds. A Worker_Should_Stop and a TM_NOREPLY command's failure re-throw
instead. The same rule answers an MCP tool error, a Log_Sources unavailable
row and the unreadable rows of workers and aggregator summary.
raw-logs read_message and read_block refuse a malformed position that way too,
and read_message answers a position holding no record as a result whose
message is null (see Log Sources).
A Table's stats command (read, no arguments) answers one map per counted
operation, GET to CHECKPOINT, each { calls, asked, answered, bytes, total_ms, max_ms, errors }; a Row_Table's carry no bytes, because its verbs
encode no keyed value. errors counts the requests the Table answered with
a TM_ERROR: a refused request, or a read its backend failed. A write that lands
fewer keys than it asked adds no error; asked less answered shows it.
dump_node and dump_metadata carry the same map as verb_stats, and
reset_stats (manage) answers it and zeroes it. The calls since the last
probe sweep carry across the reset, so the next tablestats.p0 record still
counts them.
HTTP_In_Node::set_test_mode( true ) makes dispatch() return instead of
exit(), so PHPUnit can capture stdout through ob_start().
GET /wp-json/newspack-nodes/v1/messages/stream
Server-sent-events drain endpoint backed by SSE_Out_Node, which is both the
_sse egress Node and the REST controller, mirroring HTTP_In_Node's
double-duty pattern. One endpoint covers every subscription a dashboard needs:
log partitions and worker IPC partitions both surface as Consumer_Node
instances drained in the same loop. A log or source reader sends
raw blocks, each going out as two msg events; a worker IPC
reader sends each reply, which _output must see to gate it by session, as a
msg event carrying the packed Message.
Permission: the fleet gate, then the READ role. No nonce — that would break
the cross-server SSE pull, which is the aggregator's whole job, and it is why
workers heartbeat (the slot keepalive) is read too: MANAGE there would
expire every read-only stream after one slot TTL. A session the stream
presents is checked after that, before any slot is taken: it must name a live
command session minted by the user the request authenticated as, or the stream
answers:
{ "code": "sse_session_refused", "message": "The command session is not live, or belongs to another user.", "data": { "status": 401 } }The subscriptions are resolved first, before the session and the slot. A
prefix that is not carried by any stamp (logs:x, secrets:x), a name the stream's guard
refuses (offsets:Kea-1) and a sources:<name> the registry lacks refuse the
stream with the reason; a registry that will not read is a server error:
{ "code": "sse_subscription_invalid", "message": "invalid subscription: logs/kea-7713.p3", "data": { "status": 400 } }| Field | Type | Required | Description |
|---|---|---|---|
subscribe |
string | yes | CSV of subscription names, described below. Blank entries between commas are dropped. |
session |
string | no | The handle of the command session the client signs with. Its attached commands head their FROM with _sse:{session}, and this stream passes only the replies headed with it; a stream presenting none passes none, which is what a server-to-server pull does. |
stream |
string | no | The client's own id for this stream, 1-64 characters of [A-Za-z0-9_.:-]; the browser sends its SseInNode's name. With session it keys the slot lease, so a reconnect takes its own live lease over instead of claiming a second slot, while two streams on one page keep two leases. A malformed id answers 400 sse_stream_invalid. Because the session is resolved first, a request carrying a deliberately malformed id is a probe: it answers 401 sse_session_refused for a dead session and 400 for a live one, and opens nothing. |
positions |
string | no | Optional resume positions: a JSON object keyed by the STAMP each subscription resolves to. A value is either an exact {segment, offset} object or a seek sentinel — 0 start, -1 end (live tail), -2 recent — Tachikoma's vocabulary (Consumer.pm: "valid offsets: start (0), recent (-2), end (-1)"), mirrored as Consumer_Node::SEEK_START / SEEK_END / SEEK_RECENT. The words start, recent and end are accepted aliases. Stating the seek as a number is what makes {segment: 0, offset: 0} mean the START of the log rather than an absent value: a Remote_Source states a position for every live reader it asks for, so a reader resuming a 0:0 checkpoint replays its backlog instead of being tail-seeked past it. -1 / end seeks the newest segment's current size; -2 / recent seeks byte 0 of the SECOND-newest segment, or of the only segment when there is just one, so it can replay up to a whole segment. Malformed JSON, or a value decoding to anything but an array, is treated as omitted. A per-key value is handled the opposite way: SSE_Out_Node::position_arg() reads a word of Log_Position::WORDS as its number and any other string through Log_Position::parse(), so "<segment>:<offset>" seeks that place, and any string that is neither seeks to start, so ONE typo'd position word replays that subscription's whole log rather than tail-seeking it. Omitting the parameter tail-seeks every subscription, which is what a browser dashboard does on a first connect. A value of skip (SSE_Out_Node::SKIP) keeps that stamp out of the stream: a reader never opens for it, and under a glob the stamp stays closed through every rescan while the glob's other matches open. |
multi_writer |
boolean | no | Read the subscribed logs with the multi-writer seal-grace (Consumer_Node::SEAL_GRACE_SECONDS). Set it for a log every request process on this server appends to — the firehose — where a peer can keep writing to segment N for up to Partition_Node::DRIFT_RESCAN_INTERVAL_SECONDS after N+1 appears; without it the reader advances off N on sight and orphans that straggler, typically a request's terminal process (complete). The CLIENT asserts it, because nothing on disk records which logs are shared. It applies to log Consumers only — a worker IPC attach has one writer and never takes the grace. Default off: a single-writer log seals N the instant it creates N+1, so the grace would be pure added latency. Remote_Source_Node sends it through its set_multi_writer config command; browser dashboards leave it unset. |
A standard SSE stream, Content-Type: text/event-stream, with every buffering
layer between PHP and the browser disabled and each tick's output chased with
the FLUSH_SIZE (4096) padding comment the lifecycle diagram shows.
Six events go out, in this order of first appearance. Every one but msg is a
TM_INFO frame stamped FROM=_stream, so the table names only what tells them
apart:
| Event | Message | Meaning |
|---|---|---|
retry |
KEY=retry, VALUE the sse_retry_ms config value (default 5000), or 0 at a lifetime close |
The reopen schedule, sent first because an idle close relies on it, and again as 0 just before the lifetime close of a stream that delivered records. An EVENT rather than the protocol retry: field, since the client owns reconnect and needs the interval as data it can read. The VALUE is a canonical decimal; the last one a connection carries sets the delay after its close, and 0 means at once. Set sse_retry_ms to 0 to advertise nothing at open: an idle close then leaves the client on its own backoff. |
connected |
KEY=connected, VALUE the flat envelope below |
The session handshake; see below. |
msg |
the packed Message the egress received | A raw block or its header, which the TM_BATCH bit marks and the client splits into records (see Raw blocks), or one reply from a worker's IPC channel. These count as data, which is what defers the idle close. |
heartbeat |
KEY=heartbeat, VALUE the tick's wall seconds |
Liveness every HEARTBEAT_MS = 2000ms. Deliberately not data: a heartbeat never defers the idle close. A hub's reader takes it as word that its stream has nothing more to send, which keeps the backlog the last header gave from going stale. |
unparseable_lines |
KEY=unparseable_lines, VALUE COUNT <n> COUNTS <stamp>=<n>,… CURSORS <pairs> |
Lines a worker IPC reader skipped since the last such frame because they would not unpack, sent on the tick that skipped them; a log or source reader does not read lines, and its client counts what it cannot split. The IPC reader keeps no cursor to replay a torn line from, so each is skipped rather than ending the stream on it; CURSORS is the connected envelope's token, restating every reader's resume point past the skip so a reopen does not read the line again. COUNTS lists only the stamps that skipped this tick, each with its own count, and leaves out a stamp holding a space or a comma, as CURSORS does. Not data: it never defers the idle close. The browser's SseInNode adds COUNT to a running total it publishes as UNPARSEABLE_LINES, and re-seats each subscription's resume point from CURSORS; a hub pulls no IPC stream, so its SSE_In_Node ignores the event. |
disconnect |
KEY=slot_lease_lost, VALUE SSE slot lease lost; or KEY=superseded, VALUE SSE stream superseded by its reconnect |
The terminal frame for a stream whose lease is gone. slot_lease_lost is a failure and writes a diagnostic line; superseded means the stream's own reconnect took the lease over, and writes nothing. |
The stream closes itself after sse_idle_timeout seconds (default 5)
with no msg event, and after sse_max_lifetime seconds open (default
30) however busy it is. Either is disabled at 0. The lifetime runs on the wall
clock, from Core::$now against the instant the stream opened, so a session
never holds one PHP-FPM child for its whole life: the client resumes from its
positions and takes its own slot over. After an idle close it waits out the
retry schedule, which is what gives the child back to a page that is not
being read. A lifetime close on a stream that delivered records sends retry 0
first, so a busy stream reopens at once; one that delivered nothing keeps the
gap. Neither close sends a terminal event; a disconnect frame always
means failure, and SSE_In_Node is the reference
implementation for consuming one.
SESSION <handle> SLOT <slot> OWNER <owner> SUBSCRIPTIONS <csv> INTERVAL <ms> CURSORS <csv> DISTANCES <csv>
SESSION echoes the session the stream presented and is omitted when it
presented none. DISTANCES rides with CURSORS, one stamp=<bytes> pair
for each, the bytes that reader has yet to send: a raw block's DISTANCE
stated before any block, because a stream starting caught up sends none, and a
hub reader would otherwise read its backlog as unknown until the log grew. A
file source whose named generation has rotated counts the whole current file,
which its first read rewinds to.
A flat space-separated KEY VALUE string rather than JSON, because a TM_INFO
VALUE is never an array; the slots, their validation by SSE_In_Node through
Core::canonical_decimal(), and the resume loop they seed are in the
subscription diagram above.
A log or source reader does not parse what it reads. Each read takes up to 1 MiB
(Log_Sources::BLOCK_BYTES), or 4 MiB for raw-logs read_block
(Log_Sources::FETCH_BYTES), of one segment from wherever the reader stands,
mid-line included, through
Consumer_Node::raw_block(), and
Consumer_Node::block_messages() sends it as two messages:
| Message | TYPE | ID | VALUE |
|---|---|---|---|
| header | TM_INFO|TM_BATCH |
empty | OFFSET <segment>:<offset> DISTANCE <bytes>[ RESET 1] |
| batch | TM_BATCH |
<segment>:<offset> |
the raw bytes |
OFFSET, which the batch's ID repeats, is where the bytes begin; a seek word, a
rolled segment or a rewind can move it from where the reader was asked to stand.
DISTANCE is what the log holds from there, the block's own bytes included, so
0 is the end. RESET 1 says retention deleted the segment the reader stood on,
and the block begins at offset 0 of the oldest segment left. A stream sends a
block only when it holds bytes; raw-logs read_block sends one even empty and
adds ASKED <position> to the header.
The client splits the bytes at their newlines. A record's breadcrumb is the
block's segment, its offset plus the line's byte position in the block, and the
line's byte length with its newline. A packed line is unpacked and stamped FROM
the stream; a source's line becomes a TM_BYTESTREAM carrying it. What follows the
last newline is carried and joined to the next block that continues it, so a
record longer than a block arrives whole; one the next block does not continue
was torn, as is a line that will not unpack. A batch that was not placed by any header is dropped.
A hub reader dead-letters a torn line at its place and moves past it; the browser's SseInNode, which has no
quarantine, counts it under UNPARSEABLE_LINES. A hub reader's
backlog is the latest DISTANCE less how far its cursor has drained past
OFFSET.
The application controls concurrency through four optional Closure seams on
SSE_Out_Node, installed by SSE_Slot_Pool::wire() from
Bootstrap::register_rest_routes():
| Seam | Signature | Called |
|---|---|---|
$acquire_slot |
function ( int $partition, ?array $session ): array{slot:int,owner:positive-int}|false |
Once per stream, before any header, so false can still answer 429 too_many_connections. $session is {key, ttl} — <handle>:<stream> and the seconds the session has left — or null. The shipped pool hands a live lease recorded under the key to the new stream and rotates the owner, so the old process's next check fails; it never takes a recorded slot past max_streams or inside a browser's reserved tail, and records the index for ttl seconds at most. |
$check_slot |
function ( array $lease, int $partition ): bool |
Every drain tick; false takes the disconnect close. It only READS — refreshing the TTL belongs to the client heartbeat, and refreshing it here would let an unread stream hold its slot forever. |
$release_slot |
function ( array $lease, int $partition, ?string $session ): void |
Once per stream, from the drain's finally or from a shutdown function, whichever runs first, so a close, a throw, a fatal or a client abort never leaves the slot held until its TTL expires. $session is the lease key the stream acquired under, whose takeover index the pool forgets with the lease. |
$inspect_slot |
function ( array $lease, int $partition ): array<string,int|string> |
Only once a check has already failed, to name the backend and lease state in the diagnostic line. The healthy path never pays it. |
The stream draws on one host-wide pool, sized by sse_max_streams
(6), sse_max_slots (3), sse_reserved_slots (0) and sse_slot_ttl (60
seconds). sse-host-budget.md carries the arithmetic behind
each number.
GET /wp-json/newspack-nodes/v1/messages/stream?subscribe=sources:<name>
A Log_Sources registry entry streams from
/messages/stream as the subscription sources:<name>, beside
partitions on the same stream. The name is a fixed registry NAME, opened as
a Tail_Node reader instead of a Consumer;
a caller can never supply a path, so there is no traversal surface. On the wire
a source is a partition: same msg events, retry and connected
envelopes, heartbeat cadence, flush framing, idle close and slot pool, with each
frame's FROM sources:<name>. Its blocks are a TM_BATCH like a partition's,
and the sources: stamp says their lines are text: a client wraps each as the
TM_BYTESTREAM a Tail emits, VALUE the line with its newline.
The same registry backs raw-logs. list_logs lists every source as a
sources:<name> row after the partition dirs, each row carrying available;
dump_log sources:<name> sizes one, as { log_id, segments, segment_count, total_size }; read_message sources:<name> <position> single-steps it; and
read_block sources:<name> <position> answers one raw block of up to 4 MiB. A
source whose segments will not list takes an unavailable row carrying error
beside every readable source, so one failure never blanks the picker. An active
topology that will not read takes one too, labelled for the topology and
keyed without a stamp, because it names no log to stream. Every reader of a stamp
resolves it one way (ADR-29),
so read_message, read_block and dump_log refuse what the stream refuses, with its
message.
Permission: inherited from /messages/stream — the fleet gate, then the
READ role, with no nonce. A dir carrying a .read-capability marker — a log
whose topology declared set_read_capability <cap>, its write quarantine, and
every reader's cursor and dead letters over it — also needs
current_user_can( <cap> )
(ADR-39):
dump_log, read_message, read_block and an exact subscription refuse such a
log with permission denied: log "<stamp>" needs the <cap> capability, a glob
leaves it out, and list_logs omits it. The marker stays after the declaration
goes, and a marker that will not read refuses everyone.
| Field | Type | Required | Description |
|---|---|---|---|
subscribe |
string | yes | CSV of subscriptions, any mix of partitions and sources:<name>. A sources: name the registry lacks is refused before the stream opens, as 400 sse_subscription_invalid carrying the teaching unknown log source error that lists the names that exist. A registry that will not read — an active topology with no matching .tsl, a broken include — is the server's fault: the stream logs it as a stream: <subscribe>: <cause> stderr line and answers 500 sse_subscription_unavailable with a message naming no internals. An \Error is not caught. No globs — registry sources are fixed for the life of a stream, and Tail's missing-file grace covers a source that appears, rotates or truncates mid-stream. |
positions |
string | no | Optional resume positions, same vocabulary as the partition subscriptions: a JSON object keyed by the whole sources:<name> stamp, each value a {segment, offset} object or a seek sentinel (0 start, -1 end, -2 recent), with start, recent and end accepted as aliases. File_Tail_Node folds recent to the start, because one file has no previous segment to fall back to. The shape round-trips unchanged from the client's perspective: for a segmented source segment is the segment id; for a file-mode source the file's inode occupies the same slot, and the cursor self-validates against the live file and degrades to 0 on mismatch. That validation happens on the FIRST POLL, not at subscribe time — open_subscription() seeks before the file is open, so the pair is held as a candidate, and cursor_position() echoes the client's own unvalidated value straight back into the connected envelope's CURSORS. An unchanged echo is therefore not an acknowledgement that the server accepted it. A candidate counts toward compute_lag() only while it still names the live generation and sits within the file: honouring a stale pre-rotation position would declare the new generation caught up and let the stream idle-close on its first tick without ever delivering it. Omit to start at end (live tail). |
multi_writer |
boolean | no | Inert for a source: the seal-grace belongs to a Consumer, and a source resolves to a Tail_Node. |
Two nodes call these endpoints on a remote server instead of serving them: a hub
POSTs commands to its spokes through HTTP_Out_Node and pulls their streams back
through SSE_In_Node. Both bound themselves, and an operator diagnosing a
flapping spoke reads it against the numbers below.
A hub with many spokes builds many such nodes from one line: Vault_Group
(class-vault-group-node.php) keeps
one child — an HTTP_Out, a Remote_Source, or any other type — per server in
a Vault group, running update_graph() on
the fleet's RELOAD as a spoke is added to or dropped from the group. Each child is a
normal client of the endpoints below, published as an owned sibling named
<group>:<vault id> rather than hand-written per spoke.
A batch lost past fire() has no retry and no dead letter
(ADR-3); a minter
finding Command_Auth::has_session() false calls ensure_session() rather
than skip the push, because a skipped push deadlocks both sides: HTTP_Out
handshakes only inside fire(), and only fill() arms that tick.
| Constant | Value | Bounds |
|---|---|---|
REQUEST_TIMEOUT |
15 seconds | One non-blocking POST, as CURLOPT_TIMEOUT. |
MAX_REPLY_BYTES |
8388608 (8 MiB) | One spoke's reply body by default, capped because the write callback buffers it into the PHP heap. A broker raises its HTTP_Out's cap with set_reply_cap(), never below this. |
The blocking path, probe_command() behind vault test and aggregator probe, is in the diagram: it returns the reply envelope's payload array and
nothing else, so a caller never has to read a verdict out of a returned value.
A patron (Remote_Source_Node) drives maybe_connect() and check_stale().
The node keeps no cursor. The patron states what to pull through
streams( $subscribe, $positions ) from its on_connecting seam, each time a
request is about to go out: the subscriptions in order, and a position for each
stamp, either { segment?, offset } or a seek sentinel. A position naming no
segment is a file source whose generation is not yet known. A broker builds
the request in Remote_Source_Node::stream_request(), asking each live
Remote_Consumer_Node for its
connect_position() and stating skip for a paused reader under a glob, so the
position stated to the spoke changes only at a connect, though a live reader's
cursor moves on every drain.
| Constant | Value | Bounds |
|---|---|---|
CONNECT_TIMEOUT |
5 seconds | The connect, as CURLOPT_CONNECTTIMEOUT. The transfer itself is untimed (CURLOPT_TIMEOUT 0), which is what check_stale() covers instead. |
HEARTBEAT_TIMEOUT |
45 seconds | The silence check_stale() reads as a dead stream. SSE_Out_Node heartbeats every 2 seconds, so only a broken link reaches it. |
INITIAL_BACKOFF / MAX_BACKOFF |
1 / 30 seconds | The reconnect delay, measured from the last connect attempt, doubling on each failure and reset to the floor by any received event. A clean 200 close on a stream that advertised a reopen delay — the retry event, or a plain server's retry: field — takes that delay instead, counted from the close, capped at MAX_BACKOFF and leaving the failure state untouched; a delay of 0 reopens on the next tick. A delay of 0 on a stream that delivered a msg and lived at least INITIAL_BACKOFF opens nothing inside the completion either: SSE_In calls the patron's on_reopen_due, and Remote_Source reopens from the timer scan that follows the completion, in the same drain iteration, instead of on its next 1 s tick. |
MAX_BUFFER_SIZE |
33554432 (32 MiB) | Received bytes holding no newline. |
MAX_EVENT_SIZE |
33554432 (32 MiB) | One event's accumulated data:. |
The browser mirror, src/runtime/sse-in-node.js, opens the same endpoint
through fetch(), parsing the events itself; its numbers, states and seekMap() precedence are in
the diagram. That precedence is what keeps a chart asking to replay from the
start of the log from being collapsed to a single live point by a stream the
slot pool refused before its first frame, and a glob's dirs tail-seek on the
first connect and resume from the positions they themselves reported
thereafter.
Curl_Node calls no endpoint of ours: it
fetches the url a TM_BYTESTREAM carries with a GET on the same shared multi,
through the Curl_Transfer mechanism
HTTP_Out_Node runs on. make_node Curl <name> [vault_group]. A TM_STRUCT
carries a request instead, { url, method, body, headers, proxy, response_headers, connect_timeout, timeout, public_only }: method is GET, POST or DELETE, a POST sends its
body, a string of at most the node's body cap ('' when absent), as
Content-Type: application/json, and a GET or a DELETE sends none. The cap is
DEFAULT_BODY_CAP (64 KiB) until set_body_cap( $bytes ), called in code on
the node, raises it for that node to at most 1 MiB; a value outside that range
throws \InvalidArgumentException. headers,
when present, is a list of Name: value lines sent after that type and before
the vault credential, a Content-Type among them replacing the JSON one. A
line must carry a value, since libcurl reads an empty one as removing the
header, and may not name one of Vault::FRAMING_HEADERS (Host, Cookie,
Content-Length, Transfer-Encoding, Connection, Expect, Upgrade, TE,
Keep-Alive, Proxy-Connection, Proxy-Authorization), in any case, nor
Authorization or the header the credential rides in where the vault group
lends the origin one. proxy, when present, is the <scheme>://<host>:<port>
the fetch goes through, set as CURLOPT_PROXY with CURLOPT_NOPROXY empty, so
a no_proxy variable cannot bypass it, its TLS verified as vault_verify_ssl
says: its scheme socks5h, socks5, http or https, a trailing slash
allowed, and nothing else, userinfo above all; beside a credential, the
vault's or a header line of the request's own, the url must be https. response_headers, when present, names at most eight
response headers to read; the success then answers a TM_STRUCT copy whose
VALUE is { body, headers }, each named header the final response's head
sent, a trailer never, under its lowercased name, its first value trimmed and
cut to 256 bytes. timeout, when present, is the whole seconds, from 1 to
MAX_REQUEST_TIMEOUT (120), the whole request may take, REQUEST_TIMEOUT (30)
when absent. It is a deadline set when the request starts: each transfer gets
the milliseconds that remain, read from the live clock as it starts, as
CURLOPT_TIMEOUT_MS, so a public-only request's hops, and the lookup before
each on a PHP without CURLOPT_PREREQFUNCTION, spend one budget. The deadline
passing between transfers answers timed out <url>, and passing inside one
answers libcurl's curl error 28 (Timeout was reached) <url>. connect_timeout, when present, is the whole seconds, from 1 to that
timeout, the connection may take, set as CURLOPT_CONNECTTIMEOUT; timeout
is checked first.
public_only, when present, is a boolean; true marks a url a response chose,
a discovered link or a Location, and holds the request to public addresses
(below). A header name is 1–64 letters, digits and hyphens
(Vault::HEADER_NAME) wherever one is read. Both forms share
the vault group, vault_require_ssl, MAX_IN_FLIGHT and the answer. The completion
emits a copy of the input message, FROM, ID and KEY intact and TO stamped from
target, with its whole TYPE replaced: TM_BYTESTREAM carrying the body below
status 400, and otherwise a TM_ERROR whose VALUE opens with a fixed prefix a
consumer can match.
A fetch follows redirects, up to MAX_REDIRECTS. follow_redirects( false )
turns that off for one node, and is a method rather than a positional because
the optional vault_group has no empty placeholder. With redirects off, a fetch sets
CURLOPT_FOLLOWLOCATION off, and a 3xx naming a Location answers a
TM_RESPONSE copy whose VALUE is that Location, absolute as libcurl reports it
(CURLINFO_REDIRECT_URL).
A request naming public_only true reaches public addresses alone. Before
any transfer starts, its host is refused when it is an IP literal outside the
global ranges RFC 6890 marks (FILTER_FLAG_GLOBAL_RANGE: private, loopback,
link-local, shared, reserved, and an IPv4-mapped form of any; besides the
filter, the site-local fec0::/10 and the local-use NAT64 64:ff9b:1::/48,
and a well-known NAT64 address in 64:ff9b::/96 judged by the IPv4 address
it carries), a single label
(intranet, localhost., 2130706433), or a name whose last label is
numeric (127.1, 0x7f.0.0.1), which libcurl's resolver reads as an
address; a request naming a proxy is refused, and the transfer sets an empty
CURLOPT_PROXY, which turns off a proxy the worker's environment names
(http_proxy, https_proxy, ALL_PROXY), because libcurl would report the
proxy's address rather than the host's. The lookup and the pin below run
last, after every other refusal, the busy and event-loop checks included.
Where PHP defines
CURLOPT_PREREQFUNCTION (8.4 and later), libcurl resolves the name itself,
without blocking the worker, and once connected hands the address it reached
to a check that aborts the transfer, before a byte of the request is sent,
when that address is outside the global ranges; the check runs on every
connection libcurl makes or reuses, so a name that rebinds cannot slip past
it. Where PHP does not (8.2 and 8.3), the name is looked up once, through the
Curl_Node::$resolve seam (A and AAAA records, dns_get_record(), when
null), and refused before anything is sent when it resolves to nothing or to
any address outside the global ranges; the transfer is then pinned to the
first address checked (CURLOPT_RESOLVE +host:port:address, an IPv6
address in brackets, and a rooted name pinned under both its spellings, since
libcurl matches an entry by the url's own), on a fresh connection, because
libcurl reuses a connection by host and port rather than by pinned address;
the + keeps the entry from outliving the transfer in the shared multi's DNS
cache. So a name that rebinds between the check and the connection never
reaches a private address. That lookup blocks the worker for as long as
DNS takes. Curl_Node::$prereq_available is the seam choosing the path.
Either way, once the transfer completes, the address it connected to
(CURLINFO_PRIMARY_IP) is checked again as a second line, answering a
TM_ERROR rather than the body, the headers or the Location.
While the node follows redirects, libcurl follows none of a public-only
request's: the node fetches each Location itself as the next hop, held to the
same guard, its own lookup and pin included; as
libcurl does, a hop off the first url's origin carries no vault credential,
and a 301, 302 or 303 sends a POST on as a GET with no body. The answer is a copy
of the first request's message. Past MAX_REDIRECTS hops it answers a
TM_ERROR. Following none, a 3xx answers its Location as any 3xx does, and the
sender marks the Location public_only when it fetches it. A request not
naming public_only is not guarded, and libcurl follows its redirects.
| TYPE | VALUE | When |
|---|---|---|
| TM_RESPONSE | <absolute url> |
With redirects off, the status is 300-399 and names a Location. |
| VALUE | When |
|---|---|
HTTP <code> <url> |
The response's status is 400 or more, or, with redirects off, 300-399 with no Location. |
curl error <n> (<curl_strerror>) <url> |
The transfer failed: a timeout, a refused connection, a redirect past the limit or a scheme outside it, or the body cap's short write. A request's deadline passing while a transfer runs answers curl error 28 (Timeout was reached) <url>, the libcurl text, unchanged; one passing between transfers answers timed out <url>. |
invalid url <value> |
VALUE, or a request's url, is not an absolute http(s) url with a host. |
redirect not followed: credential in <header> <url>, redirect not followed: credential in request headers <url>, redirect not followed: a DELETE <url> |
The node follows redirects, the response is a 3xx naming a Location, and the fetch followed none: the url's credential rides in a header other than Authorization, the request carries header lines of its own, or it is a DELETE. The value is never shown. |
invalid method <method> <url> |
A request's method is not GET, POST or DELETE, or is absent. |
body on a GET <url>, body on a DELETE <url> |
A GET or DELETE request carries a body, even an empty one. |
invalid body <url> |
A POST request's body is not a string. |
body over <cap> bytes <url> |
A POST request's body is longer than the node's body cap, DEFAULT_BODY_CAP (65536) until set_body_cap() raises it. |
invalid headers <url> |
A request's headers is not a list, or one of its lines is not a string opening with a name of 1–64 letters, digits and hyphens and a colon. |
header carries CR or LF <url> |
A request's header line holds a carriage return or a line feed; the line is left out. |
refused header <name> <url> |
A request's header line names one of Vault::FRAMING_HEADERS; the value is left out. |
refused header <name> beside a vault credential <url> |
A request's header line names Authorization, or the header the credential rides in, and an entry of the vault group sits on the url's origin. |
empty header <name> <url> |
A request's header line carries no value but whitespace. |
refused proxy beside a credential on a plain url <url> |
A request names a proxy and carries a credential, an entry of the vault group on the url's origin or header lines of its own, and the url is not https. |
invalid proxy <url> |
A request's proxy is not a string naming one of the four schemes, a host and a port, and nothing more but a trailing slash; the proxy is left out. |
invalid proxy carrying userinfo <url> |
A request's proxy holds an @; the proxy, which may hold a credential, is left out. |
invalid response headers <url> |
A request's response_headers is not a list of at most eight names of 1–64 letters, digits and hyphens. |
refused response header <name> <url> |
A request's response_headers names one of Vault::FRAMING_HEADERS, Set-Cookie or Authorization, in any case. |
refused response header <name> beside a vault credential <url> |
A request's response_headers names the header the vault's credential for the url's origin rides in. |
invalid timeout <url> |
A request's timeout is not an integer from 1 to MAX_REQUEST_TIMEOUT. |
invalid connect timeout <url> |
A request's connect_timeout is not an integer from 1 to its timeout, REQUEST_TIMEOUT when it names none. |
timed out <url> |
The request's deadline passed between transfers, so the transfer about to start never starts: a public-only request's next hop due after it, or, on a PHP without CURLOPT_PREREQFUNCTION, the lookup before a public-only transfer outlasting it. <url> is the url that would have been fetched. The deadline passing inside a transfer answers curl error 28 instead. |
invalid public_only <url> |
A request's public_only is not a boolean. |
refused private address <url> |
A public-only request's host is an IP literal outside the global ranges, or its transfer connected to such an address before sending, or, on a PHP without CURLOPT_PREREQFUNCTION, a name any of whose addresses is: nothing is sent. Or the address the completed transfer reports is one, and nothing of the response is answered. |
refused single-label host <url> |
A public-only request's host is one label, a trailing dot ignored. |
unresolved host <url> |
On a PHP without CURLOPT_PREREQFUNCTION, a public-only request's host is a name that does not resolve to any address; nothing is sent. |
refused numeric host <url> |
A public-only request's host is not an IP literal and its last label is digits or 0x hex. |
refused proxy on a public-only request <url> |
A public-only request names a proxy. |
redirects past 5 <url> |
A public-only request on a node following redirects was redirected again after MAX_REDIRECTS hops; <url> is the last hop. |
proxy carries CR or LF <url> |
A request's proxy holds a carriage return or a line feed; the proxy is left out. |
invalid url carrying userinfo |
VALUE carries userinfo before its host, as Vault::url_carries_credentials() reads it; the url, which may hold a credential, is left out. PHP and libcurl split an authority holding two @ at different ones, so the origin read here need not be the host fetched. |
vault entries <id>, <id>… share origin <origin> <url> |
Two or more entries of the vault group sit on the url's origin, so the fetch cannot choose between their credentials. |
vault_require_ssl set but url is not https <url> |
The url is plaintext, an entry of the vault group sits on its origin, and vault_require_ssl is set. |
no event loop <url> |
The process is not inside a drain loop, so a transfer could never complete. |
busy: <N> requests in flight <url> |
MAX_IN_FLIGHT transfers are already running; nothing is queued. |
curl_init failed <url> |
libcurl yielded no handle, so the transfer never started. |
The output goes to target like any forwarded message, never back along FROM.
Anything but a TM_BYTESTREAM or a TM_STRUCT is dropped with one rate-limited line. A transfer
still in flight when the node is removed is lost, and one rate-limited line
counts the discards (ADR-3).
Under a vault group each fetch looks up the group's entries through
Vault::in_group() and keeps those whose Vault::url_of() has the url's
origin, by Curl_Node::origin_of(): the same scheme, host and effective port.
With no matching entry, the url is fetched with no credential; an entry
with no url has no origin. With one, the fetch sends the header line its
Vault::credential_header() answers, and under vault_require_ssl refuses a
plaintext url and takes https alone, redirects included. With two or more, the
fetch refuses, naming every one. The url is fetched as
written; a vault entry's path plays no part. A credential starts on its own
origin and stays there: the transfer sets CURLOPT_UNRESTRICTED_AUTH off, so
libcurl (7.83.1 and later) drops an Authorization header when a redirect
changes host, port or scheme. libcurl sends every other header past a redirect,
so a fetch whose credential rides in another header, a request carrying header
lines of its own, any of which may be a credential, and a DELETE, whose method
libcurl would carry along, do not follow redirects: on a
node following redirects its 3xx naming a Location answers the TM_ERROR
redirect not followed, and on one with redirects off it answers the Location as
any 3xx does. A node with redirects off sends nothing past the first
response. Every fetch, vault group or not, verifies TLS as vault_verify_ssl
says, through Vault::tls_opts().
Curl_Node::origin_of() and DEFAULT_PORTS are public: Crawler_Node
reads a link's origin and default port through them, so a link is kept by the
rule a fetch is held to. transfers_in_flight() is public too: the crawler
sizes each refill by Curl's own count.
| Constant | Value | Bounds |
|---|---|---|
MAX_IN_FLIGHT |
16 | Transfers one node runs at once. |
DEFAULT_BODY_CAP |
65536 | Bytes a POST request's body may carry until set_body_cap() raises the node's cap, to at most 1 MiB: room for the largest real body, a 65,536-byte item body JSON-encoded twice (about 460 KB). A node at that cap holds up to 16 bodies, each in PHP and in libcurl's copy, 32 MiB, beside its reply buffers. |
REQUEST_TIMEOUT |
30 seconds | One request that names no timeout, redirects and hops included, as CURLOPT_TIMEOUT_MS. |
MAX_REQUEST_TIMEOUT |
120 seconds | The longest timeout a request may name. |
MAX_REDIRECTS |
5 | CURLOPT_MAXREDIRS, with CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS both http and https alone, or https alone for a fetch carrying a credential while vault_require_ssl is set; and the hops the node follows itself for a public-only request. |
MAX_REPLY_BYTES |
8388608 (8 MiB) | One response body, buffered into the PHP heap. |
Crawler_Node crawls the site of each
seed. It fetches each url through a Curl_Node
it owns, which does not follow redirects, follows the same-origin links every page
carries and the same-origin Location every redirect names, and sends each
answer to target. make_node Crawler <name> <ttl> [vault_group] [delay_ms] [concurrency]:
ttl is a whole number of seconds, at least 1, that a url counts as seen, so
a url may be crawled again once it expires; vault_group goes to the Curl
sibling. A seed may be on any origin, and each url is fetched with the
credential of the group's entry on its own origin, if any, by Curl's rule.
delay_ms, default 0, is the least number of milliseconds between
two fetch starts, and 0 leaves the crawl unthrottled; any other delay is at
least MIN_DELAY_MS (100). concurrency, default 1, is the most fetches in
flight at once, from 1 to Curl_Node::MAX_IN_FLIGHT (16). make_node refuses
a non-numeric or negative token for either, a delay_ms from 1 to 99, and a
concurrency outside 1 to 16, with Bad arguments for Crawler '<name>': …. It
extends Timer_Node and refills on a recurring timer: the one-second tick
with no delay, every delay_ms with one.
The crawler declares no commands. Its pacing is set by make_node alone, because
a worker rebuilds the graph from its .tsl at least every ten minutes and a
change made at runtime would revert without a word. A replay of its arguments
that changes only delay_ms or concurrency keeps both siblings and their
transfers, and the timer re-arms only when its interval changes; one changing
ttl or vault_group rebuilds both.
The crawler owns two siblings, built when its arguments arrive and torn down with it:
| Sibling | Node | Wiring |
|---|---|---|
{name}:curl |
Curl_Node [vault_group], follow_redirects( false ) |
Its sink is the crawler's own sink; its target is the crawler, so every answer comes back TO the crawler's name. |
{name}:seen |
Table {name}:seen <name> <ttl> sqlite |
Its sink is the crawler's own sink; it answers the crawler's Table_Client. The second <name> is the Table's namespace and its file stem. |
The Table keeps a key per url seen, written with ADD under the ttl, and
the crawler's Frontier:
pending, the urls discovered, and inflight, the urls handed to Curl and
not yet answered. A url in either set lives Frontier::MEMBER_TTL, one year,
however short the ttl, so a frontier waiting longer than the ttl keeps its
urls.
When pending takes none of the urls an ADD reported new, their seen keys
are removed again with RM, and one rate-limited line says so, so a later
sighting finds them new.
Its sqlite file is {base}/tables/{name}.p{N}.sqlite, so the crawler must be
built where a partition is bound, as a topology load binds it; built
anywhere else it throws Table_Unavailable, Table <name>: a sqlite backend needs a bound partition.
| Message | What the crawler does |
|---|---|
| A TM_BYTESTREAM whose FROM is not the crawler's name (a seed) | Its trimmed VALUE must be an absolute http(s) url with a host and no whitespace. A url that is not draws the TM_ERROR below. A valid one is normalized exactly as a link is, resolved against itself, so HTTP://Example.com#top and a page's link to / are one url; it is ADDed and joins pending when the Table reports it new; one seen within the ttl draws the TM_INFO below. Then the crawler refills. |
| A TM_BYTESTREAM, TM_RESPONSE or TM_ERROR whose FROM is the crawler's name (Curl's answer) | The url fetched is its KEY. A body's same-origin links, or a redirect's Location resolved against KEY and kept only on KEY's origin, are ADDed and the new ones join pending; the answer goes to target unchanged, an off-site redirect included; the url leaves inflight, last, so a forward that throws leaves it for the next start to fetch again; and the crawler refills. |
A reply from the {name}:seen Table |
Collected by the crawler's Table_Client. |
| Anything else | Dropped with one rate-limited line. |
A refill takes up to concurrency less Curl's transfers_in_flight() from
the frontier, one SMOVE from pending to inflight. The window fills only as fast as the
frontier does: a seed is the one url in pending until its body is parsed, so
a crawl starts with one fetch in flight whatever its concurrency, and a
redirect queues only its one Location. An answer
Curl sends synchronously, a refusal such as curl_init failed, refills
nothing, so its slot waits for the next tick. With fetches in flight, an
answer or a tick refills only once half of concurrency is free, so one
SMOVE starts several fetches; an empty window refills at once. Under a delay_ms above 0 only the timer refills: a seed
or an answer queues what it found and starts nothing, and each fire moves one
url. A delay below the Router's tick holds a slot of its own and measures
each interval from its last fire. A delay of at least the tick rides that
tick on the grid, and no start comes within the delay, less a tenth of a
tick for jitter, of the last: a fire short of it is held, and the first tick
that clears it starts the url. It hands
each url moved to Curl as a TM_BYTESTREAM whose VALUE and KEY are the url and
whose FROM is the crawler's name, which Curl copies into its answer. It runs
only inside a drain loop, and moves nothing while a recovery is owed. The
first refill after the node is built moves everything left in inflight
back to pending, Table_Node::MAX_MEMBERS_LIMIT at a time, so a restart
fetches again what the last process had in flight; a move the Table does not
answer retries on the next tick. A rename builds a new Curl sibling, since the
old one's answers carry the old name, and recovery fetches its urls again.
A 3xx answers a TM_RESPONSE whose VALUE is the absolute Location, KEY the url
fetched. The crawler discovers that Location by the rule a link meets,
resolved, normalized and kept only on the fetched url's origin, so a redirect
to another host, scheme or port is forwarded to target and not followed. A
seed on http:// that redirects to https:// leaves its origin, so seed the
final url.
The crawler answers a seed itself in three cases:
| TYPE | VALUE | When |
|---|---|---|
| TM_ERROR | invalid url <value> |
A seed's trimmed VALUE is not an absolute http(s) url with a host, holds whitespace or carries userinfo (https://u:p@host/). |
| TM_INFO | already seen <url> |
The Table's ADD reports the seed's normalized url seen within the ttl. KEY is that url, and nothing is fetched. A link or Location already seen is skipped without a word. |
| TM_ERROR | ADD to <name>:seen failed <url> |
The {name}:seen Table refused the seed's ADD or left it unanswered, so the url is not recorded as seen. KEY is that url, and nothing is fetched. |
Each is a copy of the seed with its whole TYPE and its VALUE replaced and its
TO cleared, sent to target, so FROM and ID stay as the seeder set them. Every TM_ERROR Curl answers (see Curl_Node)
reaches target as well, KEY set to the url. An answered url is done, TM_ERROR
included: it is never retried, so a permanent 404 cannot loop, and it is not
fetched again until its ttl expires.
Crawler_Node::links( $html, $page_url ) reads a page's links. It parses the
body with \DOMDocument::loadHTML() and takes every <a href>, resolving each
against the first <base href> or else the page url by RFC 3986 section 5.2:
dot segments removed, the fragment dropped, an empty path read as / and a
default port dropped. A link is kept when its origin, from the public
Curl_Node::origin_of(), equals the page's: the same scheme, host and
effective port. A link that is not http(s), carries userinfo or holds
whitespace is skipped, and each url is returned once, in document order.
One Crawler runs per name per host: its Table's namespace and file are its
name, so two workers running one crawler share the file and re-queue each
other's inflight. Topology_Analyzer claims that file in the write set, so
two active topologies declaring one crawler conflict, and lists the Table in
declared_tables(). The vault group adds nothing to either: Curl reads it per
fetch, and it builds no node. The crawler does not honor robots.txt, keep a depth
limit or check the content type, and it retries nothing; the ttl and the
same-origin rule bound the crawl.
| Constant | Value | Bounds |
|---|---|---|
TICK_MS |
1000 | The refill tick with no delay_ms, which also carries the first tick's recovery. |
MIN_DELAY_MS |
100 | The least positive delay_ms. Every fire asks the Table for a url whether or not one is pending, so a shorter delay costs an idle crawl a Table round trip that often. |
Frontier keeps a node's work where neither
a recycle nor a crash loses it, in a durable Table sibling the node owns and
asks through its own Table_Client. A node builds it beside that client,
new Frontier( $this->tables, fn (): string => $this->sibling_name( 'seen' ) ),
so the Table's name is read at each ask and follows a rename. It holds no
Table and no reply of its own: the node's fill() hands every message to the
client first, as any asker does. Crawler_Node
is its first user.
| Method | What it does |
|---|---|
add( array $members ): bool |
SADD member => value into pending for MEMBER_TTL; a member already there takes the new value and expiry. Answers whether pending took them. |
take( int $count ): array |
Up to $count members, lowest first, moved from pending to inflight in one SMOVE, as [ member, value ] pairs. A count below 1 takes none. |
settle( array $members ): void |
Take the members out of inflight: their work is done. The next take(), restart(), drain() or count() removes every member settled since in one SREM. |
restart(): void |
Owe a recovery, as a new process does, for an owner that dropped the work it had in flight. |
drain(): array |
Empty both sets, pending in take()s of MAX_MEMBERS_LIMIT and inflight in one SREM, and answer every member they held. |
flush(): void |
Remove what was settled since in one SREM, as an owner's shutdown_sweep() does on a clean stop. An SREM the Table refuses or leaves unanswered keeps those members owed to the next flush. |
count( string $state, string $prefix = '' ): ?int |
How many members of PENDING or INFLIGHT start with $prefix, every member with none, once what was settled is removed and any recovery owed has finished, as before a take(), so a successor never counts its predecessor's members as in flight: one SCARD of the Table through Table_Client::count_members(), or a count of the process's own sets for a frontier with no Table. Null when the Table does not answer the count or the recovery; any other state throws \InvalidArgumentException. |
Built with no arguments, new Frontier() keeps both sets in the process, in
the order added rather than lowest first, behind the same calls, for an owner
whose work cannot outlive it.
A member is consumed only when it is settled, so a crash costs an unsettled
member a second attempt and nothing more. The first take() a process makes,
or the first after restart(), returns everything in inflight to pending,
Table_Node::MAX_MEMBERS_LIMIT at a time; a move the Table does not answer
leaves the recovery owed, and nothing is taken until it finishes, or it would
return live members to pending. Once a take() moves fewer members than it
asked for, take() stops asking the Table until an add() or a recovery
puts members in pending, so an idle owner's tick costs no round trip. One
partition has one writer, so each member is taken once; two workers sharing
one Table would re-queue each other's inflight.
Members share the Table's key space with whatever else the
owner keeps there, as the crawler's seen keys do. A member added while it is
in flight sits in both sets until it is settled. A member settled but not yet
removed is in inflight still, so a crash before the next take() costs it a
second attempt too; a clean stop flushes it first.
| Constant | Value | Bounds |
|---|---|---|
PENDING |
pending |
The set of members added and not yet taken. |
INFLIGHT |
inflight |
The set of members taken and not yet settled. |
MEMBER_TTL |
31536000 (one year) | How long a member waits in either set. |
Topology_Registry resolves a name
through the dirs registered with it: each stock dir, the last registered first,
then the user dir. A deployment shadows what a plugin ships by registering a
dir holding the same name.
| Method | What it does |
|---|---|
resolve( string $name ): ?string |
The path of <name>.tsl or <name>.tsl.php, through resolve_file(). |
resolve_file( string $filename ): ?string |
The path of the first file of that name, or of that name plus .php, in those dirs, or null when neither file exists in any of them. Within a dir the exact name wins. Each dir answers from one listing, read again when its mtime or inode moves, so a lookup stats no file a dir does not hold, and a dir that will not list throws naming it. A directory of that name is skipped; an unreadable file still answers, so its reader fails loud. A name carrying /, \ or .. answers null, since it could reach outside the dir. |
stats(): array<string,string> |
Every registered dir in resolution order as mtime:inode, then each topology in it as mtime:size:inode, reading each dir once and stat'ing only what it lists. Bootstrap::row_table() keys its shared cache on it. |
Every hook the substrate fires, and the ones a consumer plugin is expected to
answer. Every newspack_nodes/* name and signature is frozen surface — see
stability.md.
| Hook | Arguments | Fired from |
|---|---|---|
newspack_nodes/request_graph_ready |
Command_Interpreter_Node $base_interpreter |
Bootstrap::mount_request_graph(), on every command door. Mount your service CIs here — see below. |
newspack_nodes/worker_identified |
string $type, int $partition |
Worker_Base::execute(), once the worker holds its lock, and only where Spawn_Controller tagged the process as that worker. The identity is the validated pair, never the raw request; a refused spawn, a spawn that loses the lock race and wp nodes run fire nothing. A request logger that named the process at its start renames it here. The announcement runs inside the topology load path, so a listener's RuntimeException ends the run as a malformed .tsl does: the lock is released, the throw escapes execute(), and a successor is never spawned. |
newspack_nodes/spawn_worker |
string $type, int $partition |
Spawn_Controller::spawn(). Build the worker for $type and ->execute() it. Topology_Registry::spawn_worker handles every active topology already. |
newspack_nodes/reconcile |
— | The WP-Cron event itself, on the registered 60-second newspack_nodes_minute schedule. Bootstrap::reconcile_fleet() is its handler. |
newspack_nodes/before_reconcile |
— | Bootstrap::reconcile_fleet(), before the pass. |
newspack_nodes/periodic |
— | Bootstrap::reconcile_fleet(). The minute-cadence tick for work that needs no worker, fired as the last of the reconciliation steps. The substrate's own minute work — Alerts::emit() and Job_Delay::sweep() — runs as steps of their own ahead of it, not as subscribers. The whole action is ONE step, so a subscriber that throws takes every later subscriber's window in that tick with it; the other steps all run regardless, and the failure is then written to stderr beside theirs as periodic: <cause>, behind the pass's reconcile[<pid>] process label. |
newspack_nodes/after_reconcile |
— | Bootstrap::reconcile_fleet(), after the pass's last step, whatever the steps threw, and after their failures are written to stderr, so a subscriber closing a log context closes it after them. Its own failure is written last, as after: <cause>, and the callback returns. |
newspack_nodes/restart_fleet |
string $name |
Topologies_CI_Node, once per AFFECTED fleet on a topology save or delete — a saved child restarts every parent that composes it, transitively. Worker_CLI_Command::restart_fleet_by_name() is registered on it at load and restarts every partition of the named fleet, so a substrate-side restart already happens; a name that is not carried by any active worker restarts nothing. |
newspack_nodes/declare_config_keys |
— | Config, on the first key check of the process and again on any miss. Call Config::register_keys() here and nothing else — see below. |
newspack_nodes/config_reset |
— | Config::reset(). Drop anything memoized from config: the substrate drops log-dir scans, parsed TSL and vault credentials here. |
newspack_nodes/job_worker/after_job |
string $handler, string $id, ?array $outcome |
Job_Worker_Node, always — after a success, a throw, or a decline. Tear down per-job request context here. A listener that throws fails the job: its failure joins the job's own and propagates to the Consumer. |
newspack_nodes/job_worker/batch_complete |
string $batch |
Job_Worker_Node, when a batch's last job settles. |
newspack_nodes/vault/changed |
string $id, string $action, string $previous |
Vault_CI_Node, on any credential write, a group edit included. $action is added, updated, renamed or removed; $previous carries the id a rename moved away from, else ''. Bootstrap::reload_vault_consumers() listens here to signal RELOAD to every topology holding a Remote_Source, HTTP_Source or Vault_Group, and to reset the analyzer's caches so a planning read later in the same process sees the write. |
newspack_nodes/stderr |
string $text, string $keyword |
Core::_stderr(), beside the stderr handler and under the same re-entry guard. A listener that throws cannot break the last-resort diagnostic path, and one that calls stderr() itself short-circuits to error_log rather than recursing. $keyword is what a logger files the line under: Core::STDERR_KEYWORD, stderr, for every stderr() line. Core::stderr_to_seam( $text, $keyword ) fires it with the line stderr() would compose under a keyword of its own and does not run a handler, so the line never reaches error_log; a worker writes its routine stops (stop requested, restart requested, timeout, idle: no message for <n>s) this way, under worker (stop). |
newspack_nodes/settings_after_form |
— | Admin, below the settings form. |
Hook_Node fires whatever name its required hook_name argument carries —
do_action( $hook_name, $value ) by default, apply_filters in filter mode —
so a topology can add arbitrary names to this list at runtime. A listener is
handed the VALUE alone as a single argument, never the 7-field envelope, so it
can read and (in filter mode) rewrite the payload but cannot re-address the
message: TO, FROM, ID, STREAM and TIMESTAMP never reach it, and Hook mints
nothing, so FROM crosses untouched and still names the source that stamped it.
Registering with accepted_args above 1 gains nothing. Action mode leaves VALUE
and TYPE as they stand; filter mode adopts the return AND restamps TYPE from its
shape — a list array becomes TM_STRUCT and everything else, an associative array
included, becomes TM_BYTESTREAM, which a consumer gating on TM_STRUCT then
refuses.
Newspack_Log_Node fires newspack_log with ( string $code, string $text, array $params ) — Newspack Manager's hook, not the substrate's own. Every entry
rides at 'type' => 'debug' and 'log_level' => 2, with 'data' carrying the
message VALUE when it is an array and [] otherwise. The level is the contract,
not a detail: Manager ships an entry to WPCOM and logstash fire-and-forget at
log_level 2 or above, and debug is what keeps it out of the paging path.
| Hook | Filtered value | Applied from |
|---|---|---|
newspack_nodes/topologies |
array $topologies — name => entry |
Bootstrap::get_topology_catalog() and Topologies_CI_Node. Topology_Registry::publish_catalog populates it from every registered .tsl dir, and raises a dir that will not list, naming it, rather than publish a catalog short of its topologies. It runs at the default priority 10 and skips any name already in the array, so a consumer pinning a topology's num_partitions, stale_timeout, on_demand_idle or max_runtime must hook BELOW 10 for its entry to stand; above 10 it receives the synthesized entry and replaces it. Never return [] transiently — see below. |
newspack_nodes/job_handlers |
array $handlers — name => callable |
Job_Worker_Node::load_handlers_from_filters(). A name failing HANDLER_NAME_PATTERN or a non-callable value is skipped, never refused, so one bad registration cannot cost a worker every other handler. |
newspack_nodes/remote_job_handlers |
array $handlers |
The same, for entries whose k selects the remote map. |
newspack_nodes/compositions |
array $declarations — namespace => { blocks, contexts } |
Composition::declarations(), once a process on first use. blocks is name => { title, contexts: list<string>, attributes: { name => JSON schema with a default }, container?: bool, multiple?: bool }, where shared: true replaces contexts; contexts is name => { default: string, max_blocks: int, check?: \Closure, admits?: list<string>, patterns?: { slug => { title: string, file: string } } }, default the absolute path of the consumer's markup file, admits the shared blocks, from any namespace, that stand on the context beside its own, and check( string $block, array $attributes, string $context ): void throwing \RuntimeException to refuse. A patterns entry offers a named layout: its slug matches [a-z0-9-]+, its file is the absolute path of an existing markup file, and the markup must pass Composition::validate_shape() for the context once every namespace is read, so it may name an admitted shared block; check is not run on it. Composition::schemas() lists each as { name, title, blocks } in declared order, blocks the tree validate_shape() answers for its markup, [] for a context with none. Every name sits under <namespace>/. A container holds blocks and stands only at the top, in a core/column or in a core/group; multiple: false lets a block stand once. A shared block declares no contexts and does not stand on any context of its own, and each admits name is checked once every namespace is read: it must be a declared shared block. A declaration outside its namespace, an attribute with no type or default or a default its own schema refuses, a block naming an undeclared context, a block declaring shared and contexts or neither, a context admitting a block that is undeclared or not shared, a context whose default file is missing, a pattern with a slug or title that breaks its rule, patterns declared as a list, a pattern that is not a map, and a pattern whose file is missing or whose markup the context refuses each throw \LogicException naming the namespace and the key, refusing the whole set; a throw memoizes nothing, so the next call applies the filter again. |
newspack_nodes/job_worker/before_job |
bool $run, string $handler, string $id, array $message |
Job_Worker_Node. Return false to DECLINE the job. |
newspack_nodes/capability_map |
array $map — role => WP capability |
Capabilities::cap_for(). The baseline is Roles::defaults(): all three roles map to manage_options until a site installs the granular capabilities, and then to newspack_nodes_{read,tune,manage}. Return all three roles — the union form fn ( $map ) => [ 'read' => 'edit_posts' ] + $map, never a bare [ 'read' => … ]; see below. |
newspack_nodes/oauth_resources |
array $resources — canonical resource URIs |
Oauth_Server. Declare each resource the OAuth server issues sessions for, such as an MCP door's URL; its RFC 9728 metadata is served for a declared resource alone. Non-string entries are dropped, and a non-array result declares nothing. |
newspack_nodes/command_rate_limit |
int $burst |
HTTP_In_Node::check_permission(): /command POSTs per user per one-second window. Clamped to 1 through Rate_Limit::MAX_BURST (256). |
newspack_nodes/auth_rate_limit |
int $burst |
Auth_Controller::check_permission(): /auth mints per user per 60-second window, 30 by default. Clamped to 1 through Rate_Limit::MAX_BURST (256). |
newspack_nodes/registered_log_producers |
array<int,string> $producers — path templates |
Log_Cleaner. Declare a log dir the retention sweep must know about. Non-string and empty entries are dropped and duplicates collapse, but a well-formed template that does not resolve to a directory under <config:logs_dir> anywhere in the partition range is NOT dropped: it refuses the whole sweep, offsets/ included, raising log producer <template> declares no dir under the logs root <root> before any delete, and dump_graph lists every other log and names the template under refused_producers. Core::resolve_partition_template expands each template over Bootstrap::global_num_partitions() — the global num_partitions clamped to Spawn_Coordinator::MAX_PARTITIONS, never the declaring topology's own count — so a producer writing past that range leaves undeclared dirs the sweep will take. A template carrying no {partition} token collapses to one directory pinned across the fleet, which is how alerts.p0 is declared. One log dir escapes this rule: Log_Cleaner reads Settings_Event_Writer::SETTINGS_LOG_DIR directly and seeds settings.p0 at partition 0 into an already non-empty declared set, because the settings writer has no .tsl write-set entry and does not register a template. Do not copy the settings writer as the model for a new PHP producer — one that declares nothing here is reaped. |
newspack_nodes/segment_size_overrides |
array<string,int> $overrides — basename => bytes |
Workers_CI_Node. Declare the geometry of a Partition built in PHP rather than by a make_node line, which has no literal size to read. The union keeps the left side, so the filter fills gaps and never restates. |
newspack_nodes/settings_sync/value |
mixed $value, string $option |
Settings_Sync_Node. Resolve the value a hub pushes to its spokes — a requirement for any option that can be absent, not an optional refinement. push() reads \get_option( $local ) with NO default and never goes through Config::value(), register_setting() defaults apply only inside is_admin() requests and a worker is never one, and Reset_Gate DELETES the row on reset-to-default. An unsaved or freshly-reset option therefore resolves to false, Core::as_string() makes that '', and the spoke's typed receiver refuses it with invalid value for setting: …. A hub syncing the substrate's own remote_* geometry needs a resolver mapping absent to the owning config's default; the filter runs on every push, the periodic sweep included. The substrate does not register a handler of its own. |
newspack_nodes/settings_audit_values_allowlist |
array $options |
Settings_Event_Writer, over the Settings_Schema option names. Options whose old and new values may ride in a settings-audit record; everything else is logged by NAME only. The encrypted vault option is refused BEFORE the filter runs, so a filter cannot opt the credential store back in. |
newspack_nodes/station_tab_bundles |
array $bundles |
Admin. Register a station tab bundle: handle, dir and url are required, localize and lazy optional. A malformed entry is dropped whole, so one bad contribution cannot break the others. Leave lazy off: it skips the enqueue and puts the load recipe on the station handle, but the fetch is driven by placeholders lazyTabs.js registers for the four substrate handles it names, so a bundle contributed under any other handle would simply never load. |
newspack_nodes/overlay_pages |
array $pages |
Admin::overlay_pages(). Admin page slugs the debug overlay should mount on; non-strings are filtered out. |
A capability_map callback REPLACES the map. cap_for() reads
$map[ $role ] and throws InvalidArgumentException( "unknown capability role: <role>" ) when the value is missing or empty — and when the filter returned
anything but an array. So a callback written the usual WordPress way, returning
only the key it means to change, breaks every tune and manage check. The
consequence differs by call path, and neither is a permission denial. Nothing
catches the throw on a REST permission callback (HTTP_In_Node::check_permission, Auth_Controller,
Spawn_Controller) or at admin-menu registration, where Admin passes
Capabilities::cap_for( Capabilities::MANAGE ) straight into add_menu_page(),
so a partial map is a 500 and a dead wp-admin; inside a CI command
Command_Interpreter_Node::interpret() catches it and returns it as
TM_COMMAND|TM_ERROR. wp nodes caps status resolves all three roles and throws
too.
An empty topologies result retires the WHOLE fleet. When
Bootstrap::expand_workers() comes back empty on a pass following one that saw
workers, Fleet_Node::refresh_active_set() calls drain_all_workers(), which
writes Lock_Node::RESTART_FLAG into every *.lock.d under locks/ — every
worker in the tree, not only the types that vanished — and each holder exits with
its self-respawn refused, because its type is no longer in the active set. Only a
worker's first scan is exempt, by the observed-not-assumed guard on the
previously-read set. Deactivating the last topology is the intended trigger; a
provider returning [] on an internal error, or a deploy that removes the .tsl
files under a live fleet (Topology_Registry::resolve() hits the disk on every
call), takes the same path.
Fires from Bootstrap::mount_request_graph() once the request-scope graph has
been built or confirmed already-built. That builder is shared by every command
door, not just /command, so a door never ends up with a different command surface
behind it. At this point Core's node map holds _router and
_command_interpreter (the base CI); HTTP_In_Node names itself _output
immediately after.
Signature:
do_action( 'newspack_nodes/request_graph_ready', \Newspack_Nodes\Command_Interpreter_Node $base_interpreter );Canonical usage — applications mount their service CIs through the base CI's
make_node():
function my_app_mount_service_cis( \Newspack_Nodes\Command_Interpreter_Node $base_interpreter ): void {
$base_interpreter->make_node( 'My_Service_CI', 'my-service' );
// ... more service CIs ...
}
\add_action( 'newspack_nodes/request_graph_ready', 'my_app_mount_service_cis' );make_node( string $type, string $name, ...$args ): ?Node does four things and
returns the node:
- Resolves and instantiates, via a no-arg
new $fqcn(), the first{$prefix}{$type}_Nodethat exists and is a concreteNodesubclass, looping the prefixes registered throughCommand_Interpreter_Node::register_namespace()at plugin load. Somake_node( 'My_Service_CI', … )resolvesMy_App\My_Service_CI_NodeonceMy_App\is registered. There is no per-class registry — a plugin registers its namespace prefix once (ADR-10). It returns null when every prefix fails to yield a concrete class. - Calls
$node->name( $name )so Router can find it. - Calls
$node->arguments( $arg_tokens )— the scalar positional args, cast to strings and re-indexed, as a flat token array (arguments()takes and returnslist<string>, never a space-joined string). They map onto the node's declarednode_schema()['arguments']properties, so config round-trips throughdump_config(), which re-joins the tokens viaNode::serialize_args(). A non-scalar argument is dropped with a rate-limited warning; assign object dependencies as public properties aftermake_nodereturns, as the substrate does forWorkers_CI'sCLI. - Calls
$node->sink( $this )so the node's reply routes back through the base CI to_routerand out through_output.
Redeclaring a name with the SAME class and the same tokens returns the node
already registered, so re-mounting on every request is idempotent; a genuine
collision — same name, different class or different tokens — throws. A
constructor or name() that rejects is cleaned up, never left orphaned.
Skipping make_node() — constructing and name()-ing a node by hand — leaves
it unwired. Its command responses walk back via TO=FROM
(ADR-7),
cannot find a path to _output, and drop on the floor. Always go through
make_node().
Because the hook fires on every command request, keep CIs stateless: pure command
dispatchers with their dependencies injected. For the application-side build-out,
read the per-CI node_schema() declarations under
newspack-event-logger-nodes/includes/app/.
Config::value() refuses an undeclared key: it throws unknown config key '<key>' — not declared by any registered schema rather than answer null. The
substrate derives the declared set on the first key check of the process —
registering its own Settings_Schema overlay keys, then firing this action so
every consumer plugin declares its own — and Config::is_declared() fires it
again on a miss, which is how a plugin that loads after that first read still
gets its keys accepted for the rest of the request.
Signature:
do_action( 'newspack_nodes/declare_config_keys' );No arguments. A callback calls Config::register_keys( array $keys ) with
UNPREFIXED key names, and nothing else:
function my_app_declare_config_keys(): void {
\Newspack_Nodes\Config::register_keys( \My_App\Settings_Schema::get()->overlay_keys() );
}
\add_action( 'newspack_nodes/declare_config_keys', 'my_app_declare_config_keys' );Registration is VALIDATION, not resolution. Config::load_config() builds its
map from Settings_Schema — the substrate's own schema — plus the two config
files plus the newspack_nodes_-prefixed option overlay, so a consumer's
registered key is never in that map and \Newspack_Nodes\Config::value( 'my_key' ) answers null, silently and forever — the ?? default failure this
whole action exists to prevent. A consumer owns its own Config whose value()
calls Newspack_Nodes\Config::is_declared() to validate the key and then reads its OWN
merged config — its schema defaults, its own option overlay, and the substrate
config layered underneath — as Newspack_Event_Logger_Nodes\Config::value() and
its load_config() do. The one exception is a consumer key an operator writes
into LOCAL_NEWSPACK_NODES_CONF, which is deliberately free-form and does land
in the substrate's map.
Declare from CODE — a schema, or a literal defaults array — and never from a
config file's keys, which is the rule Config::declare_keys() follows for the
substrate's own. Deriving from the file makes an operator's typo self-declaring:
the misspelling becomes valid, the real key falls back to its default, and
nothing says so. It also leaves an install whose file predates a key unable to
read that key at all. A plugin whose schema names only its overlay keys
registers the union, as nuclear-gyrobase does with its code defaults and its WP
option schema.
Register the hook at PLUGIN FILE SCOPE, not from a deferred plugins_loaded
loader. The substrate PULLS the declaration from inside a read, and that read can
precede the loader: event-logger-nodes builds its logger at
plugins_loaded:-11001, well ahead of a loader at priority 11, and value()
would throw on a real key. A plugin whose slug sorts before newspack-nodes
loads before any substrate class exists, so it hooks the literal action name rather
than the Config::DECLARE_ACTION constant.
Do nothing else in the callback. It runs from inside a config read, on any request and in any process, so a config read, an option write or I/O from here fires at an unpredictable point in the request. A read of a still-undeclared key from a callback is re-entrant — the declaring guard bounds it, but it cannot see keys a later callback declares.
Declarations accumulate and are never pruned. Config::reset() drops the cached
config and re-arms the derive, and the declared set survives it, so a dropped
callback cannot un-declare keys that already resolve. The registry is flat and
shared: every plugin's keys land in the one set, and Config::is_declared() is
the primitive a consumer plugin's own value() accessor calls to validate a key
before reading its own merged config.





![Where each dump_graph key comes from, one row per source: the lock dirs to workers[] with its seven fields, the last 512 KiB of the topicprobe log to consumers[] with its nine fields, the stale re-measure and the unparseable_lines count, the log catalog to logs[] and its join key, the logs root to log_partitions, the dead-letter dirs to the two dead-letter keys, config to the five scalars, and the active topologies to graph; beneath, the two readers of the one snapshot, the dashboard's dump_graph and Alerts::evaluate().](/Automattic/newspack-nodes/raw/main/docs/img/api-dump-graph-sources.png)





