Releases: Automattic/newspack-nodes
Release list
v2.52.0
Changed
- BREAKING:
vault testprobes the substrate's ownstatusnode and answers{ id, status }alone. It probeddiscovery, which the substrate never registers — only event-logger-nodes mounts one — so a spoke running the substrate without that plugin failed the test withserver returned TM_ERROR for probewhile being perfectly reachable under a perfectly good credential.Status_CIis mounted unconditionally bynewspack_nodes_mount_substrate_cis()and itsgetverb gates at READ, the same rolediscovery getdeclared, so no spoke has to grant anything new. It arrived in substrate 0.19.0, which is the floor this verb now carries: a spoke below that release answersstatuswith a TM_ERROR and failsvault testwhere an ELN-carrying spoke used to pass, so upgrade the spoke rather than reading the failure as a bad credential. Theresponsefield carryingregistered_hooks,custom_eventsandlagis gone: nothing read it, and those two keys are event-logger-nodes' ruleset vocabulary, sanitized a second time here besideDiscovery_Collector_Node::sanitized_names(), which owns that payload. - BREAKING: the
vaultverbs take--user=and--password=where they took--auth_username=and--auth_password=. An operator now writescmd vault update austinchronicle --user=newspack-nodes-hub --password=<app password>, and a script still passing the old spellings is REFUSED rather than ignored —addandupdatenow refuse any option they do not read, naming it and listing the ones they take. Nothing validates a verb'sargsdeclaration, so an unrecognized--key=valuewas simply an option the handler never looked up: silent in opposite directions, sinceaddstored an empty credential and answered with the id it had registered, whileupdateread the absence as "leave it alone" and answered a save that changed nothing. A stale spelling, a typo (--pasword=) and an invented flag are one bug, and the refusal runs ahead of any lookup or write. Only the two OPTION names moved: the stored keys, thelist/getpublic shape and theauth_username/auth_passwordnode arguments onHTTP_Out,Remote_Link,Remote_SourceandSSE_Inare unchanged, so every stored credential and every topology stands. The Vault admin tab sends the new spellings. A valueless option is refused with them. A bare--keyparses as boolean true and(string) trueis'1', socmd vault update spoke --password— the value eaten by a shell, or simply forgotten — stored the literal1as the credential, announced the change and answered success, leaving that spoke answering 401 with nothing in the vault to say why. Every option these two verbs read names a value, a stored field or the id an entry moves to, so the rule keys on the option's shape rather than on a list of which ones.addrefuses an absent--urlin its own words as well, whereVault::add()'s genericcheck URL format (must be HTTPS)named the wrong cause;urlis declared as the OPTION the handler reads, because the one command grammar rides arequiredarg POSITIONALLY and the console duly wrotecmd vault add <id> <url>, which the handler saw as no url at all.
Fixed
wp nodes cliprinted two prompts after every command when readline was unavailable but stdout was a real terminal. Two prompt owners collided:TTY_Out_Node::write()redrew a prompt behind each newline-terminated reply, andTTY_In_Node::drain_once()then drew its own fallback prompt for the next read. The readline path had never doubled, because it retired the prompt as readline handed a line over. Both paths now retire it in ONE place,TTY_In_Node::emit_line(), which clears both flags before the sink runs — so the reply written inside that dispatch takes the plain write path and only the fallback prompt reaches the screen. The readline callback records its line and nothing else:drain_once()emits from the queue on the very next statement, with nothing writing to the terminal in between.
v2.51.0
Security
- The cache scope was computable on any install that had never rotated its salt.
Cache_Backend::site()foldssalt()into the install scope, andsalt()returned''until an operator pressed the admin flush button or ranwp nodes memcache flush— leaving the scope derived fromDB_NAMEand the network table prefix alone, both knowable by a co-tenant on the same memcached. A computable scope is what turns a shared cache from readable into injectable:Spawn_Coordinator::load_spawn_ts()replanted stops the fleet spawning, silently. NewCache_Backend::ensure_salt()seeds one and is idempotent — the difference fromrotate_salt(), which activation runs on every plugin update would otherwise use to orphan a live keyspace each time.Bootstrap::activate()calls it, and so doesself_heal_reconcile_cron()AHEAD of its own early return, because an install activated before the salt existed has a scheduled cron and would never reachactivate()again. with_index()now requires a sole-writer partition. An.idxrow records the offset a record landed at, and on a log peers append to that offset is the writer's guess.allow_large_writes()proves exclusivity with a held lock andvoid_warranty()asserts it; with neither,with_index()throws. Every shipped topology already paired them —flames:partition,flame-stats:partitionandrequests:partitioneach callvoid_warrantyon the line before — so this makes an existing convention enforced.dump_config()already emits the large-write line first, so a graph still round-trips. The dead-letter queue followed the same rule: it armed an index unconditionally while opting into sole-writer only when the SOURCE was, so a quarantine shared with peers now goes unindexed, losing triage metadata and nothing else (wp nodes ingestreplays the.logverbatim either way).
Fixed
- A short write no longer attempts a quarantine it cannot write. The dead-letter queue is a Partition on the same filesystem, so whatever refused the batch refuses that too — the attempt only bought a second failure. The stall path is now loud, indexes nothing, and truncates the torn record only on a partition that has claimed sole writer; on a shared segment the tail rides and the reader dead-letters the line it cannot unpack. Note the cost on a shared segment: the torn record has no terminator, so the next append lands on that line and is lost with it.
scan_index()andlocate_by()no longer require a write-side formatter. Both returned early unlesswith_index()had been called, which is meaningless for a READER over another process's directory —locate_by()'s own docblock says "@api Readers resolving many keys to positions". Consumers were arming a write formatter on a read-only handle purely to unlock the read, which the sole-writer rule above then refused; event-logger-nodes did it in two places. The locator memo is keyed by directory and static, and the guard existed so a formatterless instance could not poison it with false misses — reading the real index removes that hazard instead of guarding it, because the entries such a reader records are true.
v2.50.2
Fixed
allow_replies_towas not declared repeatable, so the console could express only one. The Inspector branches on a verb'smultipleflag: without it a verb renders as a single checkbox and a single argument field, and a round trip through the console keeps the last value and drops the rest. A hub egress needs a declaration per destination —settings-sync, anddiscovery-collectoron a hub running the event logger'shub-control— so the cap was one short of the minimum, and the allowlist fails closed, which means what could not be declared was silently dropped. Now'multiple' => true, asSettings_Sync_Node::add_settingalready was;Classes_CI_Nodecarries the flag through to the catalog the console reads.
Fixed
- The Aurora Glass skin painted its surfaces translucent, so a dashboard read through to the WordPress admin behind it.
paper,paper-2andpaper-shadowwere white at 6-16% overpaper-3, and are now exactly that composite as an opaque value, so the ramp holds and nothing shows through.repl-bgcould not be treated the same way: it was a 55% scrim ofpaper-3over the canvas, which composites back topaper-3itself and leaves the REPL with nothing but its 1px border to separate it — it ispaper-3darkened to 81% instead, the same repl-under-canvas relationship blueprint, nord, synthwave, neotokyo and scada all carry. Two.theme-auroraoverrides repainting canonical roles with hardcodedrgba+backdrop-filter—.topology-headerand.topology-repl— are gone, and the defaults are correct on their own. Six--on-*contrast overrides went with them: against an opaquepaperthe derived default already clears AA. Two consequences are deliberate rather than pixel-identical: node fills no longer read the canvas gradient through, and a dialog is no longer see-through onto the page. The skin keeps its aurora canvas, accents and type. - A debug-overlay dialog escaped the panel it belonged to.
ModalShellanchored horizontally only, so a tall dialog centred on the VIEWPORT and painted well below the panel. It now centres on the panel in both axes and hands that box down as--nodes-modal-max-w/--nodes-modal-max-h, which the stylesheet narrows its standing caps with rather than replacing them — the cap is what makes the vertical anchor safe, since an over-tall dialog scrolls in its own body instead of stranding its head above the fixed backdrop. It also measures the panel it is NESTED IN, from a sentinel at the render site, instead of whichever panel a global selector found first: the DevTools hub mounts the floating overlay beside every tab, so a Console-tab dialog followed a panel it had nothing to do with. A panel too small to contain a dialog is left alone, and the dialog stays viewport-centred there as it always has. The panel's box is re-read through aResizeObserveron the panel itself rather than a window listener, becauseuseDebugFramere-clamps the panel on a window resize through a batched state update a same-event listener would read straight past.
v2.50.1
Security
- The remote chose which arm of
accept_inbound()its message landed in, and only one arm consulted the allowlist. 2.50.0 gated the REPLY arm onallow_replies_to, and the reply bit is the remote's to set — so on a target-lessHTTP_Outa spoke clearedTM_RESPONSE, fell through to the untargeted pass-through, and addressed any node in the graph. That pass-through is what the release notes told operators to avoid by pointing the egress at a Null; the gate should not have depended on their doing so. One rule now: an ADDRESSED message must name a declared destination whatever type bits ride with it, and nothing declared still means nothing addressed passes. Thetargetarm keeps its job — stamping the target onto UNADDRESSED output — and its own refusal is gone, subsumed by the declaration. - A spoke could vary its
TOto defeat the drop-rate limiter. The refusal reason interpolated the remote's path, andNode::drop_message()KEYSprint_less_often()on that reason — so every distinctTOlogged again and added a permanent entry to the process'sCore::$recent_log_timers. The reason is constant now.drop_message()itself also keyed on the message's TYPE, a bitmask of eleven flags the SENDER picks, which is 2048 keys and 2048 timers from any one drop site; the type moved to the unkeyed tail beside FROM, TO and the payload, so the key is the caller's reason and nothing the message decides. The rendered line is unchanged. allow_replies_tomatched the TO's HEAD segment, which made every declaration a prefix rule. 2.50.0 stored the head on the reasoning that a head is whatRouter_Nodepeels. But the Router peels the head and then dispatches on the REST, soallow_replies_to _routeradmitted_router/<anything>and re-opened the whole graph through the list that exists to bound it — and_command_interpreterand_fleetare the same shape. It matches the WHOLE path now, exactly: a declaredsettings-syncadmits a TO ofsettings-syncand nothing else, and a remote that legitimately answers on a deeper path is declared at that path. This supersedes the head-matching paragraph in 2.50.0's note.$matches before a trailing newline, and nothing in this tree wasD-anchored. The SECURITY-labelledHANDLER_NAME_PATTERNonJob_IntakeandJob_Worker_Nodelet"work\n"through into a line-oriented log; event-logger-nodes carries the third copy. The sweep took every anchored pattern that VALIDATES a whole token — the two handler gates,Core::canonical_decimal()(where"42\n"is not the canonical spelling of 42, which is the function's entire job), the worker-id gates inBootstrap,Cli,Spawn_CoordinatorandSSE_Out_Node,Service_CI_Node's verb-argument default,Layouts_CI_Node::ID_PATTERN, the segment-filename patterns inPartition_Node,Log_NodeandWorkers_CI_Node, the scaffold CLI's class and slug gates, the memcached server-line gate in the settings UI,Dumper_Node's session filter andTopology_Analyzer's verb and key gates. Deliberately NOT anchored:Flame_Tree::PATTERN_{START,COMPLETE},Request_Builder_Node's method line andShell_Node::VAR_GRAMMARall PARSE a line rather than validate a token — there the trailing newline is incidental, andDwould make the line stop matching and lose the capture.- The segment directory was world-traversable. Segments are 0600, but a 0755 directory still publishes the listing, and a segment name is an offset — how much was written, and when it rotated. One
ensure_segment_dir()now owns all three sites and TIGHTENS what it finds, because a mode passed tomkdirapplies once: an install already on disk would otherwise keep 0755 forever, and nothing else probes permissions (Health_Checkshas nofilepermscall). It masks group and other off rather than setting 0700, so a deliberately read-only directory is left alone, and only the LEAF tightens —mkdirwith$recursiveapplies the mode to every ancestor it creates, and a 0700logs_dirlocks out a deployment whose web request and worker run as different uids. The rotated-segment permission test pins the process umask to 022 besides: a runner already at 0077 masked the bits itself and passed against the defect the test exists to catch. Command_Interpreter_Nodeexecuted aTM_COMMAND|TM_ERRORin the JS runtime. PHP masksTM_ERRORbesideTM_RESPONSE; the JS half masked only the latter._respond()addresses a reply TO the request's FROM, so an errored command that arrived with an empty FROM came back as a fresh command — unbounded synchronous recursion. Mirrored.
Fixed
- A batch every message of which was refused at the boundary answered as if it had routed.
stamp_message()warns to stderr, the request-scope stderr sink writes that warning into_output, and the client read a 200 carrying astderrframe that retires no ask — so a client tracking replies by ID waited for one that would never come. A refused message now gets aTM_RESPONSE|TM_ERRORframe carrying its own ID, addressed back along the FROM it sent.
That warning was ALSO spending the status line.fill()decides the status on its first write and the status is spent for good, so a batch whose first message overflowedMAX_FROM_SIZEand whose second was an unauthorized command answered 200 and the 401 never ran —$out->reset()then cleared the latch and a secondstatus_header()went out behind the body.dispatch()now ASKS the newNode::can_stamp()instead of attempting the stamp, and emits both the diagnostic and the frame after the batch, once the latch has settled. The latch is deliberately not raised for a boundary refusal: an overflowing path is not an auth failure, and a 401 makesHTTP_Out_Nodeforget its session and re-handshake, which for a path the client keeps sending is a loop. settings-sync.tsldocuments the per-spoke egress in full: theNull, theconnect_node, and theallow_replies_todeclaration without which the acks are dropped. 2.50.0 required both and the topology said neither.- ADR-18 documented the refusal 2.50.0 deleted. It now states the rule that shipped: a spent remainder costs the entry its cache slot, not the read, and what a re-materialized entry is warmed for is the backing's to state.
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.
v2.49.3
Fixed
Partition_Node's locator memo capped keys per DIRECTORY while holding unlimited directories.MAX_LOCATOR_MEMO_KEYS(100,000) bounds one slot, so the real ceiling was that number times however many partition dirs a process ever touched — no ceiling at all. A reader that fans across one mirror per partition fills a slot fast, and event-logger-nodes' PHP suite, which builds a fresh temp dir per test, ran the pool to 672MB and killed PHPUnit with no message.MAX_LOCATOR_MEMO_DIRS(8) now drops every slot past that count, on the same discard-whole idiom the key cap already uses — a discard costs a re-walk, never a wrong answer. The suite peaks at 102MB.
v2.49.2
Fixed
- A dead
RuntimeExceptioncatch inJob_Intake::feed(). Documentingwrite_feed()'s@throwsmade it provably unreachable: the onlyRuntimeExceptionon that path comes from the per-partition write lock, andpartition_handle()acquires that lock insideif ( $large ), where the feed path passesfalse. The sibling catch inqueue()stays —write_job()takes the large path and can genuinely throw. lint:jsscansexamples/— it used to skip them entirely. eslint's globs namedsrc/andscripts/, andlint-contract.mjswalkedsrc/alone, so the AI-newsletter example sat on threename-lookup-in-optionviolations while the gate reported clean. An example is the code a reader copies, so a violation there teaches itself onward.SCAN_ROOTSnow carriesexamplesbesidesrc;walk()returns nothing for a root that does not exist, so a plugin with no examples scans exactly as before.useStreamGraphdeclares whatsetPausedactually takes. Its@returncalled it a bareFunction, which carries no call signature, so the three consumers that correctly declare(paused: boolean) => void— event-logger-nodes' glob, error-log and request-log hooks — each failed the type gate in their own repo with the cause a repo away.- The AI-newsletter example passes its view CLASSES, not their registered names.
CommandInterpreterNode's name table is a per-bundle static (ADR-16), so a name resolves only through an interpreter that bundle mounted, and a hub tab building the graph through another bundle's would find nothing.../nodes/registerstill runs, for the TSL and palette lookups that have no class to hand.
Documentation
- The twenty-two documents verified against source, then completed from it. Two passes held every existing claim to the code, the second re-deriving each rather than reviewing the first's reasoning; 195 claims across the corpus were refuted, among them
TM_COMPLETIONattributed to this runtime when it is Tachikoma's flag and 512 isTM_NOREPLYhere. Everyfile:linecitation became a symbol reference, several having already drifted —Core::print_less_often()was cited 22 lines early. Then all 931 non-test PHP and JS files across the six plugins were read to establish what these documents omit, and each resulting finding was verified against source again before being written. - Every source file's documentation reviewed against its code, one agent per file. All 412 PHP and JS files, tests excluded: missing docblocks written,
@param/@returndrift corrected against the real signature, documentation for deleted behaviour removed, and the why recorded where the what was already plain. Comments that described history rather than the present are gone. Threenode_schema()descriptions were wrong about their own node —Lock_Nodeadvertised a file lock where it takes a directory withmkdirand a PID heartbeat. The second half turned up more of the same:ProbeStreamViewNodeclaimed its per-record path was O(1) and published nothing, wherefill()sweeps every live key and_maybePublish()fires on the leading edge;useTopologyManagerdocumented adispatchAwaitedthat exists nowhere in the tree;TreeEntityput its indent at 14px per level where the nested wrappers make it 39px. - JSDoc types tightened where prose had loosened them. A
@return {Function}carries no call signature, so every typed prop it reaches becomes unassignable and the error lands in a file whose author cannot see the cause;useDebugFrame's resize handlers,TopologyRow'sonToggleFoldandTopologySection'sonToggleall carry real signatures now.
v2.49.1
Fixed
lint-comments.phpno longer wraps a config ledger's own entries. A ledger documents every key by showing the declaration commented out, so the line's width is the code's, not a sentence someone chose to run long — wrapping// 'objecteditor_url' => '/wp-admin/admin.php?page=…'makes it stop looking like the line it documents. Scoped to ledgers and matched withLEDGER_ENTRY, the constant the block rule already identifies them by, so no other file can hide a long line behind a//. Ledger prose still obeys the budget.
Changed
- The config ledger's entries are aligned and uniformly indented.
v2.49.0
Added
- The inspector shows Routing for an included node. A borrowed node rendered its constructor and verbs but no targets, so the one thing this document can say about it — where its output goes — had no control.
LockedFormnow renders the same editableTargetsFieldthe owned form does, on the samenodeHasTarget()guard. It is not a contradiction of the read-only include: the INCLUDE half stays the defining topology's, while the lines this document aims at the node are this document's, which is whathandleUpdateVerbsalready assumed for verbs.
Fixed
- Flush Caches sits on the Save Settings row. It posts its own form, which is why it had been stranded on a line of its own; it now submits that form from a hidden sibling, exactly as Reset to Defaults already did.
- A settings checkbox is no longer flush against its label.
Settings_Renderer::checkbox()emitted<input><label>adjacently, so the box and its first word read as one glyph. 6px between them. - A chart legend swatch is no longer flush against its label. The text sat at
x=14, four pixels clear of a ten-pixel swatch; it now starts atx=18.
v2.48.0
Fixed
- Query and outbound-HTTP spans get their own colors. Both are named
base: detail, sogetStateColor()resolves them on the base — and neithersqlnorhttpwas inSYSTEM_COLORS, so they fell through to the same grey asrequest. That left the two most expensive things in a trace the two least visible in it. HTTP takes#42A5F5, the hook categorizer's own HTTP color, so a span reads like the hooks around it; SQL takes#8E24AA, a hue none of the 63 categories inhook_categories.jsonuses.