v2.50.0
Security
- A spoke's reply chose its own destination inside the hub, and every node routes.
HTTP_Out_Node::accept_inbound()returns early for aTM_RESPONSE/TM_ERRORso a reply can self-route on the FROM breadcrumb we minted — theRemote_Linkheartbeat and event-logger-nodes'Discovery_Collectorboth depend on it. But the REMOTE sets the bit that makes a message a reply, and that early return precedes thetargetrefusal twelve lines below whose docblock names this exact case: "an addressed non-reply arriving while a target is set is the remote picking its own destination inside us — refused." Every node sinks into_command_interpreterand then_router(ADR-7), so the spoke'sTOis routed, not decorative: on a live aggregator hub that is thirty names,_router,_command_interpreter,_fleetand two offsetlogs among them. Command execution stays blocked — the verifier is process-wide, the reply is unsigned, andMessage::LOCALcannot crossunpacked()— but a node'sfill()is reachable, andDiscovery_Collector::fill()merges an unsolicited payload straight into the hook picker's staging options.
Newallow_replies_to <path>verb onHTTP_Out_Nodedeclares which destinations a reply may address; anything else is dropped with the path named. Matching is on the TO's HEAD segment, because that is whatRouter_Nodepeels: a declared head admits the deeper path a breadcrumb echo arrives on, and what the remainder means belongs to the node receiving it. Fail closed: an empty list admits nothing addressed.Remote_Link_Nodedeclares its own name beside the target it already re-addresses inaddress_null_sink(), so a patron's heartbeat needs no operator action and survives a rename. This is HTTP_Out's first declared verb, so__construct()now callsauto_wire_interpreter()— without it there is no<name>:configsidecar, the verb is unreachable, and the allowlist fails closed with no way to open it.dump_config()replays each declaration so a graph round-trips.
A per-spoke egress needs a target AND a declaration. With no target set,accept_inbound()passes an addressed message from the remote whatever it carries, so a target-less egress admits MORE than a declared one — point it at a Null, asRemote_Link_Nodedoes for its own patron.
Operator action required on any hand-wiredHTTP_Out. A per-spoke egress created from the topology console — the shapesettings-sync.tsldocuments — carries no declaration and will drop its acks until one is added:cmd <name>:config allow_replies_to settings-syncand, on a hub running the event logger'shub-control,allow_replies_to discovery-collector.
Changed
-
A durable backing's record now outranks the cache TTL it was written with.
Table_Node::read_through()refused an entry whose stated remaining life had run out — "a STATED lifetime that ran out is a miss, not a resurrection." That treats a cache TTL as a statement about the DATA, and it is not one: event-logger-nodes keeps a fine URL bucket two hours in memcache because the fine tier is the largest thing that schema puts in a 512MB cache, while mirroring it for twice the stats window. The refusal therefore made the durable tier useless for exactly the data whose cache lifetime is shortest — an evictedurls_hkey could never be rebuilt from the fine buckets decision 17 says it derives from, which is the premise for leaving the coarse tier unmirrored at all. The entry is now SERVED either way, and warmed only when its stated remainder is positive: a spent one is not worth a cache slot, and a backing that wants an entry gone stops returning it.Stats_Storesupplies the honest number —window_remaining(), seconds until the bucket leaves the RETENTION window — so a 23-hour-old hour re-warms for an hour rather than for a fresh full one. -
The cross-language statement pin no longer pins line NUMBERS.
tests/fixtures/statements/*.jsonare the golden the PHPStatementFrontEndParityTestand the JSparse-statements.fixture.test.jsboth hold their front end to. They carried each statement'sline, so editing a COMMENT in a shipped.tslfailed both suites and demanded a fixture regeneration — churn standing between an author and a comment rather than a guarantee. Both halves now droplinebefore comparing. The parser still emits it andShell_Nodestill accumulates it; it is diagnostic, and what the pin is for is the verb, its arguments and the raw text.
Fixed
- A rotated segment was created world-readable.
get_handle()wraps itsfopeninumask( 0077 ); the rotation path created the next segment withtouch()outside that guard, so under the usual 022 every segment after the first landed0644— and a firehose segment carries request URLs and environment values. Confirmed live before the fix:firehose.p0/1520.logwas-rw-r--r--while the same partition's first segment was-rw-------, and a read as another uid returned a real record. /commandingress ignoredstamp_message()'s documented refusal.Node::stamp_message()returns false and leaves FROM UNCHANGED when the stamped path would passMAX_FROM_SIZE, and its docblock states the contract — "Refused two ways, and the caller must drop the message on either." The two other call sites honour it;HTTP_In_Node::dispatch()discarded the boolean and filled the message anyway, so a FROM in the eight-byte window between 1017 and 1024 bytes reached the graph unstamped, escaping the_outputboundary and naming its own reply destination.- The command interpreter re-dispatched a
TM_ERROR, so a refusal answered a refusal. Every refusal is mintedTM_COMMAND|TM_ERRORaddressedTO = FROM, and the interpreter's gate excluded onlyTM_RESPONSE— whereCommand_Auth::is_request_command()already excluded both. Each pass minted another, inside one PHP process. Vault::is_valid_id()accepted a trailing newline.$also matches before one, so an id that the method's own docblock requires to stay unquoted in an audit line and in a message path could split the record it was written into. Anchored withD.
Removed
GET_LAG, the Consumer/Tail request verb, is gone — it had no production caller. Nothing in the substrate or any consumer plugin sent it; the only callers were the REPL'srequest_node, the console Inspector and the tests. Lag itself is untouched:compute_lag()remains the one seam,probe_stats()andidle_since()still read it, and an operator reads the distance offwp nodes status'sBehindcolumn, which renders the probe record'sdistance. With the verb wentConsumer_Node::handle_request(), therequestsentry in itsnode_schema()— the key is absent now, not empty, sohelp Consumerand the Inspector render no request section — andConsumer_Node::fill(), whose only reason to exist was dispatching that verb. Removing the override changes no behavior: its body had becomeparent::fill(), andTail_Node::forward_line()'sparent::fill()now resolves toNode::fill()directly, which is where its TM_BYTESTREAM already landed. The six tests that read the reply payload for its lag arithmetic — including the two wedged-cursor regressions from 0.15.0 — now call thecompute_lag()seam instead, so that coverage survives the verb.
Documentation
- Six documents, plus three docblock citations in the example plugin. The architecture guide now says the substrate ships TWO request verbs,
GET_HEALTHand Table'sGET <key>, and namesJob_Worker_Node::handle_requestas the canonical example; it also dropsConsumer_Node::fill()from the list of overrides that inherit the FROM stamp by forwarding throughparent::fill.writing-a-plugin.mdmoves its "shape to copy" exemplar toJob_Worker_Nodeand addsTable_Nodeas the deliberate contrast — case-SENSITIVE match, noTM_RESPONSEbit, no{verb, data}envelope, an unknown verb dropped with no reply at all.getting-started.mdno longer promises a requests section forhelp Consumer. The three demo nodes inexample-ai-newslettercitedConsumer_Node::handle_requestfor their reply shape and now citeJob_Worker_Node::handle_request, which matches it line for line.AGENTS.md,nodes-reviewandnodes-debuggingfollow, the last pointing an operator atwp nodes status'sBehindcolumn instead of a verb.