Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Changelog

v7.26.4 (2026-10-05)

The tests and their tooling, after four rounds of cloud verification of the suite’s speed work (REV-CLOUD.md). One real defect fixed: 18 tests linked the kernel archives by a bare name and ran on the old library after a change of the one they test. No change in the libraries or in the yunos, so a node running 7.26.x needs no upgrade. The suite passes in parallel on the dev machine (298/298, ctest -j8) and on wattyzer (298/298, -j8). The yunetas CLI goes with it: 0.21.2.

v7.26.3 (2026-10-05)

A fix of the test suite: 7.26.2’s suite failed on two nodes running it in parallel (hidraulia, artgins), because the groups of test_c_treedb_literal_wins exhausted the per-user inotify instances. No change in the libraries or in the yunos, so a node running 7.26.0 to 7.26.2 needs no upgrade.

v7.26.2 (2026-10-05)

A second release of the test suite and its build: no change in the libraries or in the yunos, so a node running 7.26.0 or 7.26.1 needs no upgrade. On the dev machine (8 cores), yunetas test with no change in the sources takes 226 s in all, against 470 s at 7.26.1 and ~31 minutes before 7.26.1. A machine with a root build/ from an earlier release stops building the SDK in it at its next build of that tree (the changed CMakeLists.txt re-runs its configure).

v7.26.1 (2026-10-04)

A release of the test suite and its tools: no change in the libraries or in the yunos, so a node running 7.26.0 needs no upgrade. yunetas test (CLI 0.21.0) compiles with make -j and no make clean, and runs ctest in parallel from this SDK on. On the review’s 4-core machine, a run with no change in the sources went from ~31 minutes to ~7-8.

v7.26.0 (2026-10-04)

What changed after 7.25.22: five changes an operator or a developer has to know about (each in the upgrade steps below) -- a key delete signalled to the rt_disk followers with the master’s sequence (a protocol change between master and followers), with_link_events on by default, the tm markers of timeranger2 removed (an API removal), write-attr limited to SDF_WR attributes, and a negative t/tm bound that selects rows again -- the md2 rows read in blocks and the match condition parsed once per scan, and the defects of five reviews and of TODO.md, each with a test that fails on the code before it unless the entry says otherwise.

Performance, against 7.25.22

Measured against 7.25.22, each release built from its own tree, the two run alternately (8 rounds, 24 for the timeranger2 tests); the report is performance/reports/7.26.0.html. Reading a timeranger2 history is 16-17% faster (184,338 -> 215,157 records/s; page by page 166,017 -> 192,697), opening a treedb 20% faster (390 -> 311 us per node), a replica opens 12.6% faster (108.4 -> 94.8 ms, no tm marker looked for), and a tm query of one minute of a key on a topic 7.25.22 had NOT marked takes 12.8 ms instead of 402 (31x): the md2 rows read in blocks of 1024 and the match condition parsed once per scan. The prices, each with its reason: the same query on a topic 7.25.22 HAD marked takes 12.8 ms instead of 7.5 (+70%: no tm markers, every row read). Found by this A/B and fixed before the tag: a master that opened a topic wrote its delete_seq.json durably, which doubled the creation of topics and made the first open of 40 treedbs 55% longer; written not durably now (it holds 0), both are back within the noise of 7.25.22 (134 ms for 10 topics, 10.3 s for the 40 treedbs). Not measured: a key delete on a topic with an rt_disk feed, which by construction writes the record of its sequence first. Everything else within its noise.

Upgrade steps (operators, read first)

v7.25.22-3 (2026-10-02)

A packaging revision, not a new version: the code is 7.25.22’s, and the packages are attached to the existing tag 7.25.22.

v7.25.22-2 (2026-10-02)

A packaging revision, not a new version: the code is 7.25.22’s, and the packages are attached to the existing tag 7.25.22.

v7.25.22 (2026-10-02)

What changed after 7.25.21: the defects, the nits and the risks of two reviews -- of 7.25.21 itself, and of the fixes made for it -- and the open defects of TODO.md section 1. Each fix has a test that fails on the code before it, except the hook and the entries this list marks “(no red test)”. Two items stay open, in TODO.md: the accounting of key deletes in rt_disk followers (its design is decided: a sequence in the master’s signal, which changes the protocol between processes, so it gets a release of its own), and code of the projects, moved to their own TODO files.

Performance, against 7.25.21

Measured against 7.25.21, each release built from its own tree, the two run alternately (8 rounds, 24 for the timeranger2 tests); the report is performance/reports/7.25.22.html. A forced treedb delete takes 39% less time (69.8 -> 42.5 us): a delete no longer walks every open key. Nothing is slower on a path this release changed. Four figures moved outside their spread on code that did not change, and are not claimed: appends -1.9% per second (-1.6% with a live reader) and a treedb update in memory +5.0% of time, slower; the appends to a store of many keys -8.5% of time, faster -- placement of the code in a whole-release build.

Upgrade steps (operators, read first)

Fixes

The reborn key of an rt_disk follower (the HIGH open since 7.25.20)

Risks the review named

v7.25.21 (2026-10-01)

What changed after 7.25.20. Each behaviour change has a test that fails on the code before it, except the few this list marks “(no red test)”.

Upgrade steps (operators, read first)

Performance, against 7.25.20

SECURITY: secrets that still reached a reader or a log

Kernel

timeranger2 and its tools

MQTT

Agent

Control center

emailsender and webstats

gobj-js 7.25.9 and gui_agent 0.29.7

Tooling and house rules

Known limitations

v7.25.20 (2026-09-30)

The last hole of the secret masking of 7.25.19, and the performance study of the fifteen releases since the last one (7.25.5): the report of this release measures 7.25.20 against 7.25.5.

Kernel: list-persistent-attrs masks the secrets too

Performance, against 7.25.5

v7.25.19 (2026-09-30)

The end of the lost webstats report of wattyzer, and what it turned up: the report’s IP addresses as [a.b.c.d] (OVH read one as a phone number and dropped the mail), secrets masked where attrs are shown, the persistent-attrs file 0600, and the SMTP refusal text in the log. A kernel change, so the suite ran on both machines (215/215 each); deployed as emailsender and webstats on every node -- the other yunos take the masking at their next build.

Kernel: secrets are not shown, and not left world-readable

emailsender: a refused login says why

webstats: the report’s IP addresses go out as [a.b.c.d]

v7.25.18 (2026-09-30)

emailsender and webstats only: no kernel change. Found chasing a webstats report of wattyzer that stopped arriving (OVH accepts it -- 250 queued -- and it is lost after the relay; the cause is still being narrowed down, see TODO). Each node takes it with install-binary of the two roles and the usual promotion.

emailsender: the smtp trace no longer writes the credentials

webstats: a rebuilt day with no access log keeps (and sends) the stored report

webstats: send-yesterday

v7.25.17 (2026-09-30)

Kernel: C_TCP_S and C_UDP_S answer help

JS: gui_agent 0.29.5

v7.25.16 (2026-09-30)

A lite release (control center and agent only): the two points the review of 7.25.15 left in TODO.

Control center: two editors of one scenario

Agent: a yuno that runs but is not connected is answered for

JS: gui_agent 0.29.4

v7.25.15 (2026-09-30)

A lite release (control center and agent only, rule of 2026-09-29): only those two binaries change, and only they are deployed. It closes what a review of the scenarios and their live view found, after running the yunovatios-stress scenario end to end from the console.

Control center: a web client is its connection; runs end when their agent goes

Agent: watch-yuno-stats tells two tabs apart, and takes what it can

JS: gui_agent 0.29.3

JS: tabulator-tables ^6.6.0 in every SPA; gui_agent 0.29.2, gui_treedb 0.17.73

JS: gui_agent 0.29.1

v7.25.14 (2026-09-29)

A lite release (controlcenter only, rule of 2026-09-29): only the control center’s binary changes, and only it is deployed.

Control center: the scenarios, in its treedb; its dead topics gone

JS: gui_agent 0.29.0, the Scenarios workspace, in place of Monitor and Statistics

JS: gui_agent 0.28.0, the Users workspace

v7.25.13 (2026-09-29)

Agent: watch-yuno-stats, the stats of yunos pushed to whoever watches

JS: gui_agent 0.23.0 - 0.27.0, the Monitor workspace

tr2check: check a topic filled by a load test

CLI 0.20.2: sync-binaries uploads the file it compared

CLI 0.20.1: yunetas init keeps the ctest logs

v7.25.12 (2026-09-28)

timeranger2 (fs_watcher): a pass says where its time went

emailsender: rejected SMTP credentials stop the yuno, they are not retried

CLI 0.20.0: secret overlays removed (BREAKING)

emailsender: a blank password leaves the SMTP side stopped until set-email-user

Packaging: colas2.sh works again when called without arguments

v7.25.11 (2026-09-27)

Found by the third gate-outage test of yunovatios’ central: three “Event NOT DEFINED in state” that a connection going down produced, all in the window between a transport deciding to close and the layers above learning it; and a pass after an inotify overflow whose own cost grew with the tree. Rebuild every yuno (TCP and websocket clients, rt_disk followers) and the agent.

root-linux: data that arrives or leaves while a connection closes

timeranger2 (fs_watcher): the pass after an overflow, at a cost that does not grow with the tree

yuno_agent, controlcenter: an answer for a client that left

v7.25.10 (2026-09-27)

The recovery from an inotify overflow (7.25.9) now keeps the yuno answering: the pass over the watched tree runs in slices of 20 ms per loop turn. Found by the second gate-outage test of yunovatios’ central, where one pass held a db_history_ce deaf for four minutes. Every yuno that reads a timeranger2 topic of another yuno must be rebuilt against this SDK.

timeranger2 (fs_watcher): the pass after an overflow gives the loop back

v7.25.9 (2026-09-27)

An inotify queue overflow no longer aborts the yuno: the watcher recovers in place and its owner rebuilds its view from the filesystem, where what the lost events said still is. Found by yunovatios’ stress test of its central, where a db_history_ce following a busy db_tracks_ce lived in a crash loop under a burst. Every yuno that reads a timeranger2 topic of another yuno (an rt_disk feed) must be rebuilt against this SDK.

timeranger2 (fs_watcher): an inotify overflow no longer aborts the yuno

v7.25.8 (2026-09-26)

A TLS fix in C_TCP: under a burst, a connection could have two writes in flight, and a short one then sent its rest out of order -- the peer answered “bad record mac” and dropped the link. Found by yunovatios’ stress test of its central, where the drain after a gate outage barely converged because of it. Every yuno that speaks TLS must be rebuilt against this SDK (the gclass is linked into each binary).

TCP (C_TCP): TLS “bad record mac” under a burst -- one write in flight

v7.25.7 (2026-09-26)

A leak fix and nothing else in C: every return from inside a SWITCHS case lost a compiled regex, and every gate with an output queue returned from one per message. Found by yunovatios’ stress test of its central. The JS submodules move to maplibre-gl 6.11.2 and vite 8.3.1. Every yuno must be rebuilt against this SDK to lose the leak.

Helpers: a return inside a SWITCHS case leaked a compiled regex

JS: gobj-ui 7.25.23, gui_agent 0.22.99, gui_treedb 0.17.72

v7.25.6 (2026-09-25)

What changed after 7.25.5: the tests that failed on the nodes, one of them over a real defect of the static resolver, and the open-files limit of the agents. No change in timeranger2, treedb or the transports.

Event loop (yev_loop)

Packages (deb, rpm)

Tests

v7.25.5 (2026-09-25)

What changed after 7.25.4. Each behaviour change has a test that fails on the code before it, except those listed under “No red test” in TODO.md.

Upgrade steps (operators, read first)

Security

Data loss and integrity

Scans and lists (timeranger2)

Performance, against 7.25.4

Measured with the benchmarks under performance/c/ (perf_timeranger2, perf_tr_treedb, perf_c_treedb, perf_rotatory, new in this release, and the yev_loop ones) and the test timeranger2/test_topic_pkey_integer, each linked against the module of 7.25.4 and of this release, run alternated (means of 8, 10, 14 or 20 rounds, some of them on 4 link layouts; RelWithDebInfo with memory tracking, ext4, laptop NVMe). The last fixes (keys that cannot be listed, the delete guard, the schema leftovers, the rotatory newfile callback; then the instances of a delete, the pkey2 load, the readdir failures, the schema order, the audit of a JSON text inside a JSON text; then the pkey2 save guard, the audit’s quote-proof scan, the rotatory exit_on_fail, the directory walks and the queue backups, and the C_UDP_S read buffer; then the instances that follow an unlink or a delete of their parent, the rotatory size limit in bytes, the audit of escaped names, the yev_loop fd close, and the kw of a subscription that rewrites it; then the ring entry a stop reads, the newest record of a key, and the match of a repeated subscription) were measured by an A/B of each change alone, on a machine shared with other builds; the pkey2 load, the C_UDP_S round trip, the publish and the subscribe each with a program written for it (none in the tree). The raw figures, with their spread, are in performance/c/README.md.

Schemas (C_TREEDB)

Event loop (yev_loop)

Transports and inter-yuno events (C_TCP_S, C_UDP_S, C_IEVENT_*)

Agent, gobj-c and tools

C_NODE, C_AUTHZ, C_TRANGER

JS: gobj-js, gobj-ui, gui_agent, gui_treedb

BREAKING

Known limitations

v7.25.4 (2026-09-23)

The 2026-09-23 review of the 2026-09-22 treedb/timeranger work

A third, read-only review of the fixes of 2026-09-16 and 2026-09-22 (six reviewers, repros against outputs/lib) found 12 mediums and ~35 lows that no test caught. All of them are fixed below, each with a test that was red before where one could be written.

BREAKING

Security and integrity

Correctness

New tests: test_stop_reopen, test_append_md_contract, test_rt_disk_multi_feed (feed hijack), tr_treedb_files 20, tr_treedb_hook_rename 5, a walk of C_TREEDB’s command table refusing a denied user plus replica refusals, N11 with a real save over a dict-shaped file, test_command_delete_user 8-9, test_late_record in both directions, and the N1 traversal test given a real loop and a reachable victim.

gobj-ui 7.25.5, yunos-js gui_treedb 0.17.57 / gui_agent 0.22.78

C_TREEDB: apply-schema asks create-delete, save-schema write

yunos-js gui_treedb 0.17.56

v7.25.3 (2026-09-23)

BREAKING in this release (flagged after the fact, 2026-09-23)

These behaviour changes shipped in 7.25.3 without the flag:

gobj-js 7.25.0

gobj-ui 7.25.4, yunos-js gui_treedb 0.17.55 / gui_agent 0.22.77

The lows of the 2026-09-22 review, C side

gobj-ui 7.25.3, yunos-js gui_agent 0.22.76

timeranger2: a marked md2 file is not read whole at every wake-up of a follower

C_TREEDB: saved-schema says which topics the draft changes

gobj-ui 7.25.2, yunos-js gui_agent 0.22.75

C_TREEDB: the diff of a save reads a schema file whose topics are a dict

C_NODE: activate-snap answers result 0; C_AUTHZ: enable-user answers the refusal

C_TRANGER: a dead id is free again, a backward page counts from the live end, a key born again is a deleted key

timeranger2: an unfiltered iterator pages the key as it is

timeranger2: the id of a disk feed stays inside its topic

C_NODE: update-node with create_only asks for create

timeranger2: the append returns its metadata in the out-param, g_rowid included

timeranger2: an append names its file once

timeranger2: an append reuses the integers of the cache it updates

webstats: each top client says whether fail2ban banned it

tools/fail2ban: escalating bans for the probe jail, and a readable fail2ban.log

webstats: the top clients are named -- country and organisation, from RDAP

v7.25.2 (2026-09-22)

gobj-c: gbuf2json_from_peer() -- a frame from a peer that is not json is a warning

C_TREEDB: saved-schema on a treedb with nothing saved no longer logs an error

v7.25.1 (2026-09-22)

C_TREEDB: impose_c_schema is configuration, not a persisted attribute (BREAKING)

A treedb_schema_<db>.c is what the schema editor exports: the graph, then the literal

v7.25.0 (2026-09-21)

C_TRANGER: a multi-key iterator holds no iterator per key (M22)

The memory half of M22 of the 2026-09-21 review.

treedb: the fkey mark is derived, and no file carries it -- a hook can be renamed

M2 and M3 of the 2026-09-21 review.

C_TREEDB: an edit of a schema is a draft; save-schema publishes it, apply-schema puts it in use (BREAKING)

M36 of the 2026-09-21 review, the owner’s design. With gobj-ui 7.23.196 (the schema editor stops raising versions and marks drafts) and gui_agent 0.22.74 (Save, the imposed banner, and an Apply dialog with the relaunched yuno and the changes).

treedb: a now column is stamped by every write, writable or not

M5 of the 2026-09-21 review, the owner’s option B.

treedb: a rowid id is not handed out again under an active snap

M4 of the 2026-09-21 review, the owner’s option A.

M11 and M12 of the 2026-09-21 review, the owner’s option A.

The loose ends of the 2026-09-21 review: M15, M26, M31, M41, M42

treedb + C_TRANGER: a schema write is checked and published whole; a session takes its lists

M6, M7, M8 and M21 of the 2026-09-21 review (TODO.md).

timeranger2: a topic name stays inside its database, and a replica cannot delete a topic

A1, A2 and A3 of the 2026-09-21 review (TODO.md).

C_TRANGER: deleting a key no longer stops the master through an open iterator

A5 of the 2026-09-21 review (TODO.md), its first half.

timeranger2: a failed READ never exits the process

A5 of the 2026-09-21 review, its second half (the decision: a read that fails has written nothing, so it is no reason to leave).

C_TRANGER: a handle is judged by its identity, not by its topic’s name

A4 and M22 of the 2026-09-21 review.

timeranger2: block 7 of the 2026-09-21 review (the cache cell)

C_TRANGER + gui_treedb 0.17.53: block 4 of the 2026-09-21 review

gobj-ui 7.23.193: block 3 of the 2026-09-21 review

The kernel/js/gobj-ui submodule moves to 7.23.193 (its CHANGELOG.md has the detail): a row delete crosses the confirm dialog by the row’s id and no longer by its position, which could delete another record (A6); a refused Save keeps the form open on what was typed (M25, new attr form_waits_for_answer with EV_WRITE_DONE / EV_WRITE_REFUSED); a card of the graph follows its UPDATED again (M33); a __graphs__ echo no longer marks other topics’ unsaved layout as saved (M32); and M24, M27, M29, M30.

Block 2 of the 2026-09-21 review: disable-user, the agent deletes, snaps

v7.24.1 (2026-09-20)

gbmem: the leak audit does not follow what it writes, and says which ref it caught

v7.24.0 (2026-09-20)

The treedb GUI round (gobj-ui 7.23.186-7.23.192)

C_NODE: schema-file, the schema as it is STORED

treedb: a snapshot freezes the ARRANGEMENT of the treedb too

treedb: a now column is stamped by every write, not only by the create

treedb: an instance a snapshot froze is not deleted either

The build date of a yuno says its zone: ISO 8601 UTC (CLI 0.19.3)

treedb: a write made while a snap is activated is untagged (BREAKING for who relied on it)

The Developer window is readable (gobj-ui 7.23.184-7.23.185)

treedb: a collapsed view with metadata is no longer a “pure node”

Each treedb topic table has its own Raw JSON (gobj-ui 7.23.183)

Toolbars: the common buttons keep one order (gobj-ui 7.23.182, gui_treedb 0.17.48, gui_agent 0.22.70)

v7.23.0 (2026-09-19)

upgrade-yunos shoots no snap for nothing (CLI 0.19.2), tranger cards show every column (gui_treedb 0.17.47)

Rows of every key of a topic (C_TRANGER open-iterator rkey, gui_treedb 0.17.43)

gobj-ui 7.23.181: a JSON viewer given its document at create expands

The Developer window: TRAFFIC and TRACES are two feeds (gobj-ui 7.23.176-7.23.180)

Found using the window on the deployed treedb GUI, with Traffic ticked alone to read what was going to the backend -- and getting a list of event names beside a browser console showing the four payloads.

Consumers: yunos/js gui_treedb 0.17.37-0.17.41 / gui_agent 0.22.64-0.22.68, with the five new i18n keys (collapsed, payload, traffic payload folded, traffic payload laid out, show the payload of the traces). Deployed to artgins.ytreedb.com, artgins.yunetacontrol.com and .ovh.

impose_c_schema now projects the schema into __system__ too

__system__ is the only place a schema can be ASKED for -- from ytreedb, from gui_agent, from any node command. A treedb that only ever opened with impose_c_schema on (the default) had no projection at all, so the schema it runs could be read from its binary and nowhere else.

Opening with impose still does not READ __system__ -- the treedb opens from the schema in C -- but the MASTER now writes it in the two cases where nothing of anybody’s is lost:

Projection in __system__What happens
none for this treedbseeded from the schema in C
schema_version lowerre-made from the schema in C
schema_version equal or higherleft as it is

The third row is the one that matters: a write to __system__ publishes itself by raising the version, so a dynamic edit is never overwritten by the projection, whatever impose does to the schema file. It stays readable with diff-schema and comes back by turning the flag off, exactly as before.

Only the master writes __system__, which the projector did not check before: a replica reads the treedb from disk as it is at that moment and reconciles nothing, the migration of legacy ids included. It used to attempt the write on an ordinary open too, where a non-master treedb keeps it in memory, never reaches disk and logs nothing.

Inside a projection that IS being re-made, impose applies at topic level as it does on disk: a topic is written because it DIFFERS, not because its topic_version is higher. Under the ordinary rule the projection would describe a topic the store no longer holds -- the one case impose exists to repair. An identical topic is not re-appended.

The log line of that open says “system not read” where it used to say “system ignored”. treedb_open_db()'s options parameter also documents "impose" in its prototype now, not only in the block above it.

Tests: c_treedb_system_schema test 13 pins the three cases -- seeded, re-made, left alone -- and test 14 the master rule: it takes the master’s C_TREEDB down, opens the same store again as a replica, and checks that it READS the projection the master left and moves nothing when a literal far ahead is imposed on top. Tests 9 and 10 already pinned that an edit in __system__ survives an imposed open.

treedb: a snapshot freezes what it shot (BREAKING for who relied on the latest snap following)

The design question the 2026-09-15 review left. A save inherited the snap tag the node carried in memory: after shoot-snap S every later update was written INTO S, so activate-snap S answered the updated content and only the creations made after the shot were reverted. The latest snap never froze; a snap only did when the next one was shot. The agent’s rollback -- shoot before a deploy, activate if it goes wrong -- reverted the new rows and kept every change to a row that already existed, and nothing said so.

current_snap_tag() was there for this and nobody called it. Now treedb_save_node() and treedb_create_node() tag a record with the snap that is ACTIVATED, 0 when none is: in normal operation every write is tagged 0, so a snap holds exactly what was live when it was shot; an edit made inside an activated snap stays inside it. treedb_shoot_snap() is unchanged.

Two things follow from the tag no longer riding on updates:

Tests: tr_treedb_snap_clone (an update after the clone freezes neither snap; a node a snap holds is not deleted, updated or not) and tr_treedb_files test 13, rewritten to the new semantics. Against the previous library both fail on the frozen-snap assertions.

A sweep of the minors the post-implementation audit left

Nothing here changes a happy path.

Traces: a saved scope replaces main()'s defaults, and the global no-trace is a command (C and JS)

A yuno’s main() sets trace defaults before it creates the yuno -- above all gobj_set_global_no_trace("timer_periodic", TRUE) -- and C_YUNO restores what the user persisted with the trace commands. The restore only ADDED levels, so a default the user turned off came back at every restart, and the global no-trace could not be turned off at all: there was no command for it (DEBUGGING.md said so, “by design”). And save_global_trace() deleted the __global_trace__ key when its last level went, so “none” was not something a user could keep.

Migration note: a trace_levels / no_trace_levels saved by an older release holds level-by-level lists, and those now replace main()'s defaults for their scope too. A gclass no-trace list that misses a default of main() loses it; fix it with set-gclass-no-trace, or clear the attr with remove-persistent-attrs.

The JS side moved in the same round: gobj-js 7.22.0 gives the JS C_YUNO the C yuno’s trace_levels / no_trace_levels and its trace commands with this same rule, removes the yuno attrs (tracing, trace_timer, trace_inter_event, trace_creation, trace_start_stop, trace_subscriptions, trace_i18n, no_poll) the old dev panel wrote, and decides every trace by its bit (C_IEVENT_CLI’s traffic is its own level ievents). gobj-ui 7.23.172’s Developer window (7.23.173 fixes a gclass NOT FOUND its Traffic chip logged in an app with no websocket) sends those commands and never analyses messages: its “Periodic” filter -- which counted signatures and hid a whole burst of commands as “recurring” -- is gone, and Periodic is now the timer_periodic level, i.e. EV_TIMEOUT_PERIODIC. Mute and No poll are gone. gobj-ui v1 1.0.4 (npm legacy) ports its dev panel to the commands; the mains of hidraulia, estadodelaire and yunomusica stop passing the removed attrs, and the setup_locale() of six apps stops reading trace_i18n from the yuno.

Found using the deployed window, same round: gobj-js 7.22.1 fixes current_timestamp(), which wrote UTC time followed by the local offset (two hours wrong at +0200); gobj-ui 7.23.174 keeps in the window what arrived before it was opened, leaves payloads out of Name only / Compact, and gives FIND a clear button. Then gobj-js 7.22.2 makes the console and the window show the same lines: a kw is dumped only with ev_kw (a publication and a subscription printed it under machine alone), and a log sink installed late is handed the last 600 lines written before it; gobj-ui 7.23.175 stamps each row with the time the line was written.

gobj-ui 7.23.171: the treedb views say the keys of a topic

kernel/js/gobj-ui -> 7.23.171, yunos/js and every consumer on ^7.23.171, deployed by the round to the eight hosts. A topic with pkey2s keeps several instances under one id, and tkey says where the time of a record comes from. None of the three treedb views said either one:

(t) is new to the notation, so the legends of the three literals that carry one (treedb_schema_authzs.c, treedb_schema_yuneta_agent.c, treedb_schema_controlcenter.c) gained the line. It is a comment only. JS API doc links repinned to 7.23.171.

gobj-ui 7.23.170: the form’s Save sends the pkey2 back

kernel/js/gobj-ui -> 7.23.170, yunos/js and every consumer on ^7.23.170, deployed by the round to the eight hosts. Found auditing 7.23.168 (A8 of the 2026-09-15 review): the rule for what a form writes back -- writable, fkey, file, or the pkey -- left out the SECONDARY keys. yunos.yuno_release is persistent, required and not writable, so the update-node went out with no pkey2 and C_NODE resolved it to the PRIMARY instance: right by chance while the topic table lists primaries, wrong from a form opened on a row of instances, or on any topic whose pkey2 column is not writable (configurations.version, public_services). A pkey2 names the instance the update is for, and it goes back for the same reason the pkey does. The rule lives in treedb_write_plan.js now, pure and tested with pkey2s as a list and as the bare string of a C literal. JS API doc links repinned to 7.23.170.

C_TRANGER: a paging session is watched once

Found auditing 7.22.0’s “a session that only PAGES no longer leaks its iterators”. watch_owner() subscribed C_TRANGER to the session’s EV_ON_CLOSE on EVERY open-iterator / open-rt, on the strength of a comment that said gobj_subscribe_event() returns the subscription already there. It does not: given the same (event, filter, subscriber) again it logs “subscription(s) REPEATED, will be deleted and override” with a stack trace, deletes it and creates it anew. So every handle of a session after its first -- a gui_treedb tab with two Rows cards -- was a warning and a stack trace in the yuno’s log. The reaping itself was right. It now asks gobj_find_subscriptions() first and subscribes only when nothing is there. Test: c_tranger gains a paging session -- a real C_IEVENT_SRV, created and never started -- that opens two iterators (watched once, no log), and then closes: both iterators are reaped, the watch goes, and the session’s parent hears the close once. Against the previous library the second open logs the warning. That test is also the one 7.22.0 shipped without.

Found auditing 7.21.0’s “a link into a single-valued fkey moves the child”. That fix made _unlink_nodes() clear a string reference only when it names the parent being unlinked, and log otherwise -- and then it went on: it published EV_TREEDB_NODE_UNLINKED for a link that did not exist and saved the child, a record identical to the previous one. Reachable from the wire with C_NODE’s unlink-nodes. The array and dict shapes of an fkey had the same fall-through since the first version, and the dict HOOK side removed an absent key in silence, so a mismatched unlink there reached the event with no log at all.

The check is now made once, before anything is touched, on the child’s reference -- the half of a link that is persisted -- and it decides: a child that does not name that parent is not unlinked from it. The call answers -1 with “Cannot unlink, the child does not hang from that parent” (in place of the three “Parent ref not found in … child data”), the hook and the child are left as they were, no event is published and nothing is saved. A link that IS there behaves as before. Test: tr_treedb_relink, whose mismatch case now counts the UNLINKED events and the child’s g_rowid. Against the previous library it fails on all three: the call was not refused, one event fired, one record was appended.

treedb: the rowid counter survives a topic_version change

Found auditing 7.21.0’s fix (“a rowid id is never handed out twice”). The counter it introduced, last_rowid_id, lives in the topic’s topic_var.json, and tranger2_create_topic() REMOVES that file when the schema’s topic_version goes up (or down while a treedb imposes its schema), so a key the new schema no longer carries goes away with it. The counter went with it too. At the next create get_next_rowid_id() seeded itself again from the ids still alive, and if the highest id had been deleted it was handed out again -- the one thing the fix forbade, because a snap’s id rides the records it tagged. The agent’s yunos, configurations and public_services are rowid topics whose topic_version moves between releases.

It showed only across a RESTART: in the same process the topic stays open in the tranger and keeps the counter in memory, so a close and reopen of the treedb alone could not see it, and the existing test did not. The counter is now read before the file is removed and written back after the file is re-created; every other key still follows the schema. Test: tr_treedb_rowid gains a case that deletes the highest id, shuts the tranger down, starts it again with the version raised, and creates. Against the previous library it hands out 4, the deleted id, for 7.

v7.22.0 (2026-09-16)

JS: gobj-js 7.21.0, gobj-ui 7.23.169, and the yunos that ride them

gobj-js 7.21.0 closes the kwid_* review: kwid_find_one_record() crashed with a TypeError when there was no data (it read .length off the null kwid_collect() answers for a kw that is neither a list nor a dict, and one caller hands it the data of a command answer, which is missing exactly when the command found nothing); kwid_new_dict() dropped a record without id in silence where the C twin logs it; kwid_new_list() is ported from C with its semantics. 19 new cases in tests/kwid.test.js, which nothing covered before.

The version jumped 7.16.6 → 7.21.0 and skipped four: the first two indices are the SDK’s, and this package had drifted again.

gobj-ui 7.23.169 closes the nine gobj-ui findings of the 2026-09-15 treedb review — every action crosses the FSM now (the Op-column pencil, the confirm dialogs, the kws that carried G6 event objects, the writes run from DOM callbacks), the table’s search no longer matches the COUNT of a hook, ac_unselect_rows() reads its own attr, the toolbar and the search box carry title/aria-label, the form dialog’s title re-translates, and the first graph.render() guards against its own view being gone.

yunos/js: gui_treedb 0.17.36 — every deferral is a posted event, the scan watchdog is a C_TIMER child, the connections table’s row actions are reachable from the keyboard, and a REPLICA opens without its write buttons (the discovery asks treedb-info per C_NODE service and stores it, because the library reads readonly once, when it draws a topic’s toolbar).

tools: audit-agents.sh says when an agent runs a binary that is gone

Every node runs yuneta_agent plus yuneta_agent22 as a deliberate redundancy, and a spare left behind on an old binary is invisible until the day it is needed — five days on four nodes, running the version-comparison bug 7.12.0 fixed. install.sh restarts the spare after a package upgrade, so the runtime nodes are covered; a node that BUILDS FROM SOURCE has no install.sh.

tools/agent/audit-agents.sh is read-only and exits 2 if an agent is not running, 1 if one is stale, 0 otherwise, so it drops into a cron unchanged. The test is one line and it is EXACT, not a heuristic: Linux refuses to write into a binary that is being executed (ETXTBSY), so cp over a running agent fails outright and install / mv / a package succeed only by unlinking first — which leaves the running process holding an inode with no name, and the kernel marks its /proc/<pid>/exe (deleted). Every replacement that can happen while the agent runs is one the kernel marks, so there is no in-place case to miss.

C_TRANGER: a session that only PAGES no longer leaks its iterators

mt_subscription_deleted() reaps the realtime feeds and iterators a SUBSCRIBER leaked, which covers every client with a Live card. A client that only PAGES -- open-iterator + get-page, no open-rt -- subscribes to nothing, so nothing ever told the service its session had died: its iterators lived until mt_stop, one per Rows card per dead session. gui_treedb browsing without a Live card did exactly that, and a filtered iterator costs more than an empty handle -- it holds its row index, one rowid per matching record, so a card over a wide time range pinned a proportional array.

The session is watched directly now. C_IEVENT_SRV publishes EV_ON_CLOSE when it goes, so watch_owner() subscribes C_TRANGER to it the first time that session opens a handle -- a subscription of OURS to IT, which asks nothing of the client and needs no new command -- and ac_on_close reaps by the same src_gobj stamp the subscriber path already used. The two reaping loops are one function now, reap_handles_of(), called from both.

Guarded by the gclass NAME of src (C_IEVENT_SRV), because src is not always a session: a local caller is some gobj of this yuno that does not outlive us anyway, and the one thing that must NOT be watched is C_IEVENT_CLI, whose mt_subscription_added forwards every explicit subscription to the remote peer. A gobj_has_output_event() test cannot tell the two apart -- both publish EV_ON_CLOSE. (This paragraph said gobj_has_output_event() when 7.22.0 was cut; the code never did.)

C_TREEDB: delete-treedb refuses a treedb that is OPEN

The command deletes the SCHEMA of a treedb -- its projection in __system__ -- and it did not ask whether the treedb was running. It deleted it under one, without a word: the treedb went on answering from the copy it holds in memory, its schema no longer existed anywhere, and the damage landed at the NEXT open-treedb -- the C_TRANGER service of the old one is still alive under its name, so the create collides and the open dies with an internal “tranger client NULL” that names nothing an operator can act on. The store on disk is orphaned by then: data with no schema to read it by.

It refuses now, naming the way out (close-treedb, or the yuno’s own lifecycle with pause-yuno + play-yuno) -- the guard its sibling close-treedb already had, for the same reason. force does not lift it: force already means “yes, delete the schema” here.

The // TODO falla, hay que revisar that sat on the delete itself is gone, and so is the diagnosis TODO.md carried, which was wrong on both counts: the parent IS deletable before its children (force unlinks them itself), and the collapsed view a tree hands mt_delete_node is only read for its id, which then re-resolves the pure node. What that path does with a CLOSED treedb was right all along.

Test: c_treedb_system_schema gains test 12 -- a treedb of two topics and three columns, deleted twice. Closed, nothing of the projection may remain; open, the command must refuse and change nothing. Red against the previous library on both halves.

mqtt: a protocol warning says WHO sent the packet

137 of the 142 decoder warnings of c_prot_mqtt2.c named a malformed or hostile packet without naming its sender, which is not actionable on a broker with a thousand sessions. They carry peername now, read through a peer_of() helper that answers "" when the bottom gobj is already gone -- a late error is logged after the transport is torn down.

The 71 gobj_log_error of that file were left alone on purpose. The scope is the one CLAUDE.md sets for decoder severity -- “could a remote peer trigger this with bad bytes?” -- and an internal invariant is not the peer’s doing: a field naming a peer for a fault that is ours reads as an accusation.

c_prot_http_cl.c was read as part of the same sweep and has nothing to migrate: its seven logs are config errors or internal ones, and the only one that looks like a decoder is building our own request. It carries the url now, which is what an operator needs of a bad request -- a peername there would name the server we chose.

testing: a log assertion that does not have to know the order

set_expected_results() matches every captured log against the head of the expected list, so the list states the exact messages, in the exact order, the exact number of times. That is the right assertion for a test that drives one sequence, and it stays the default for all 137 tests.

It is the wrong assertion when two independent gobjs log the same thing and nothing orders them against each other. c_tcp2/test2 is the case: the client and the accepted server side both log "Connected" / "Disconnected", and the driver calls set_yuno_must_die() from inside one side’s close callback, which logs "Exit to die" synchronously and shuts the yuno down. The other side’s last log is swallowed -- or is not, when the two close completions land in the same io_uring batch. So the count moved and not only the order, and no ordered list could be right: the test failed on a busy box naming a message that was perfectly correct.

New set_expected_results_unordered() reads the same list as a WHITELIST: a log matches any entry, a match does not consume the entry, every entry must still be matched at least once, and anything matching no entry fails. It gives up “in this order, this many times” and keeps “these things happened, and nothing else did” -- which is what that test’s own comment always claimed to check. Documented in docs/doc.yuneta.io/test_suite.md, with the bar for reaching for it.

c_tcp2/test2 uses it; nothing else changed. Verified both ways: a message that happens and is not whitelisted fails, and a whitelisted message that never happens fails.

v7.21.0 (2026-09-16)

C_TREEDB: mt_treedbs answers the list its contract promises

Last finding of the 2026-09-15 review (M-C8). A framework method answers what its contract says, and gobj_treedbs() says “a list with treedb names”. C_TREEDB’s mt_treedbs answered a msg_iev_build_response envelope when the user had no read: a dict, which a caller reads as a treedb named result. It now answers NULL, and says why in the log (MSGSET_AUTH) -- a NULL with no message would be a silent error. The allowed path already answered a list; C_NODE’s mt_treedbs always did.

Latent: no in-tree caller reaches C_TREEDB’s mt_treedbs (the only gobj_treedbs() call is C_NODE’s treedbs command, on a C_NODE, and C_TREEDB publishes no treedbs command). Test: c_treedb_system_schema asks gobj_treedbs() for a list and for the refusal. Against the previous library the refusal case fails.

C_NODE: every command that reads or writes the treedb asks for a permission

From the 2026-09-15 review. C_NODE checks a permission inside each command handler, with or without the global enable_command_authz gate. Only nodes (read) and the node writes did. Every other read answered anyone the routing gate let in, and so did four writes. It matters only in a yuno with an authz checker (C_AUTHZ), which is the case of the agent, the controlcenter, the logcenter, mqtt_broker and every gate and database of the projects.

help, authzs, system-schema (the compiled meta-schema) and trace (a framework switch, which belongs to the global gate) ask for nothing, as before. A refusal answers -403 No permission to '<permission>' in service '<treedb>', as nodes always did. Test: c_node_authz (new). It uses a checker that grants by user name. Against the previous library it fails for every command listed above.

timeranger2: an append to an earlier file goes to that file’s place

From the 2026-09-15 review (M-T2). A key’s cache has one cell per md2 file, in the order the load gives them (by file name). A global rowid is a position in that order. The append path looked only at the LAST cell, so a record whose __t__ belongs to an earlier file got a second cell of that file at the end. Triggers: append-record __t__=, tr2migrate, tr2q_mqtt, or a clock stepping back across the filename_mask.

Now the record goes to its file’s cell wherever that cell is. A new file’s cell goes to its place in the order. The global rowid handed out is the record’s place, the same number a reload gives. For an append to the last file it is the total, as it always was. One consequence cannot be avoided: a record for an earlier file moves every record of the later files one place up (B goes from 2 to 3). A reload always did this. Now memory and disk agree at once. Test: test_out_of_order_append (new). Against the previous library it fails every check, on the master and on the follower.

timeranger2: a follower hears a deleted key once per feed; a feed can close itself from its callback

From the 2026-09-15 review (M-T1, M-T3). Reproduced with a master and a follower that had inotify armed. test_delete_key_propagation never armed it: it put yev_loop in the config, which tranger2_startup() overwrites with its parameter.

No runtime code registers key_deleted today. The stale follower cache affected every follower. Test: test_delete_key_propagation. Its rt_disk case now arms inotify, and it has a new case with a real follower. Against the previous library it fails in every case: 0 fires in process, 6/0/6 and 0/0/0 on the follower, and a self-closing feed that neither fired nor closed.

treedb: an update cannot change a pkey2 value; the snapshot clone moves the node’s metadata

Two findings of the 2026-09-15 treedb review. Both were reproduced against the installed library before the fix, and each now has a test.

treedb: a cycle in one hook is refused, and every cycle is freed at close

A hook holds the child NODE, so a cycle of links is a cycle of json references. It was accepted, it survived a reload, and it leaked at every treedb_close_db(): measured at 15 676 bytes for two nodes, and nothing without the cycle. A cycle in ONE hook (a under b and b under a through departments) also sent children recursive=1 and jtree into endless recursion. Neither walk had a visited set or a depth limit, so a client that could link and read could overflow the yuno’s stack.

Test: tr_treedb_relink.

treedb views: the form writes back only what goes back; a JSON drill keeps its path (gobj-ui 7.23.168, gui_treedb 0.17.34)

kernel/js/gobj-ui -> 7.23.168, and the same range in the consumers (npm run deploy-round). From the 2026-09-15 treedb review (A8, A9, A10):

Tests: tr_treedb_schema_parse (two hooks refused, one hook parsed twice clean) and c_node_link_events.

timeranger2: a topic opens whole on a filesystem without d_type

On XFS with ftype=0, NFS, FUSE or overlay, readdir() gives no d_type, and find_keys_in_disk() asks the inode with stat(). It joined the entry to the topic’s directory instead of its keys/. <topic>/<key> does not exist, so no key counted, and the topic opened with an empty cache over files that were all there: reads answered 0 rows, and the first append started a cell {rows:1} over a file that already held N, so the wrong record was served from then on. Dead code on ext4 and xfs with ftype=1, which is why nothing saw it. It now joins keys/ (with build_path()). The #else branches of this function and of the file lister in helpers.c used variables they never declared, which compiled only because Linux defines DT_DIR. Test: tests/c/tr_dt_unknown, which links with -Wl,--wrap=readdir to hide d_type from a fully static binary.

C_TREEDB acts only on the treedbs it opened; C_TRANGER’s delete-topic asks for force again

close-treedb, create-topic and delete-topic found their target with a global gobj_find_service(treedb_name) and acted on whatever it returned. close-treedb treedb_name=treedb_system_schema (with the yuno paused, or force=1) stopped and destroyed C_TREEDB’s own __system__ treedb, whose handles live in its private data. The next open-treedb, diff-schema or mt_stop then used freed memory. Any other service name was destroyed the same way, and create-topic / delete-topic read the tranger attr of whatever they found. Now all three refuse a target that is not a C_NODE opened by this C_TREEDB (its parent), and they refuse the __system__ treedb: “‘<name>’ is not a treedb opened by this service”. Every caller in the tree closes on the service it opened with, so none is affected.

C_TRANGER’s delete-topic deleted a topic WITH records without force. It counted the records with tranger2_topic_size(topic, NULL), which passes the topic where the tranger goes and no topic name. That lookup failed (“Cannot open topic”), the count came back 0, and the guard “topic with records, you must force to delete” never fired, so any topic was deleted on the first call. It also read force without KW_WILD_NUMBER, and the command line delivers force=1 as an integer (“path MUST BE a json boolean”). The second bug was hidden by the first. Both are fixed: a topic with records is refused without force, and force=1 from the command line is honoured. delete-key, two functions below, always did both right.

Tests: c_treedb_system_schema (the refusals, with force) and c_tranger (delete-topic refused without force, done with an integer force).

treedb: a column is hook or fkey, never both (BREAKING for such schemas)

A column flagged ['hook', 'fkey'] was accepted, and its fkey side was never written to disk. convert_node2tranger() wrote the column with the shape of a hook, so every link that stored its reference there was gone after a reload, and nothing said so. The loader had a second defect in the same place: it keyed the parent’s hook dict by the wrong reference. No schema in the SDK or in any project uses the pair, and gobj-ui’s schema editor already refused it (“the treedb writes one half of a link, never both”).

parse_schema() now refuses such a column (“A column cannot be both ‘hook’ and ‘fkey’”). That is the validation every yuno runs before it opens its treedb, and open-treedb runs it too. treedb_create_topic() refuses the topic (“Topic refused: a column is both ‘hook’ and ‘fkey’”), because create-topic is a live command and a parse failure there only logs. A node that is both a child and a parent carries two columns: the hook, and the fkey. The test schemas that used the pair now do exactly that (tr_treedb: a new departments.manager fkey behind the managers hook). The rule is in CLAUDE.md and YUNO_TREEDB.md. Test: tests/c/tr_treedb_schema_parse.

A string column with the fkey flag holds one parent, so a new link REPLACES the old one. _link_nodes() wrote the new reference and left the child in the hook of the parent it hung from before, a phantom child. A delete of that parent without force was refused (“has down links”). With force it “unlinked” the phantom, and _unlink_nodes() emptied the child’s string without checking which parent it named. That cleared the reference to the NEW parent and saved it, so after a reload the child hung from nobody.

Now a link first unlinks the child from the parent its string names. That also publishes EV_TREEDB_NODE_UNLINKED for it, so a re-link emits UNLINKED + LINKED where it used to emit only LINKED. An unlink only clears a string reference that names that parent. Otherwise it logs “Parent ref not found in string child data” and leaves the child alone. Also fixed: _unlink_nodes() went on to a switch on a NULL value after “field not found in the node”, and _link_nodes() printed a const char * with %j. C_NODE’s seed-link guard is unchanged: it refuses to overwrite a seed’s link before any of this runs. Test: tests/c/tr_treedb_relink.

treedb: a rowid id is never handed out twice

A topic whose id column carries rowid hands out the id when the create sends none. It was tranger2_topic_size() + 1, and that function sums the RECORDS of every key: deleting a node lowered the total, so the next id landed on one that already existed, and every update raised it, so the ids skipped.

The id is now one past every id the topic ever handed out, kept as last_rowid_id in the topic’s topic_var.json and raised past any numeric id already in the index, which also seeds it in an existing store: nothing to migrate. An id is never reused, because a snap’s id rides the records it tagged: a new snap with a deleted snap’s id would inherit them. Ids no longer skip on updates, so new ids in an existing store continue from the highest one present. Test: tests/c/tr_treedb_rowid.

treedb: a column that declares no flag no longer crashes the yuno

Every user column is validated against the cols topic of treedb_system_schema, where flag is an enum that is not required. A column without flag reached check_desc_field() with its value NULL, skipped the required test, and the enum branch switched on json_typeof(NULL): SIGSEGV. It was reachable from C_TREEDB’s create-topic (which passes the cols it receives), from open-treedb with a schema, and from any C schema literal. An absent value that is not required now has nothing to check, as the branch for the basic json types already said; an unknown flag is still refused. Test: tests/c/tr_treedb_schema_parse.

tranger2_str2system_flag() maps each name to its own bit

idx_in_list() counts from 0 and the function applied a count from 1, so every name landed on the bit of the one before it: sf_string_key gave 0, sf_rowid_key gave sf_string_key, sf_int_key gave sf_rowid_key, and sf_t_ms / sf_tm_ms gave bits nobody reads. It went unseen because every caller in the tree passes "sf_string_key" and tranger2_create_topic() falls back to a string key when the topic has a pkey. The one real path was C_TRANGER’s create-topic system_flag=sf_int_key, which created a rowid-key topic, and a sf_t_ms that was silently dropped.

Only NEW topics are affected: an existing topic reads its system_flag from its own topic_desc.json. A topic created by hand with that command keeps the flag it was created with. An unknown name is now logged (“Unknown system_flag name, ignored”) instead of being dropped in silence. Test: tests/c/timeranger2/test_str2system_flag.

audit-sshd.sh runs clean over stdin

ssh node 'sudo bash -s' < tools/sshd/audit-sshd.sh audits a node without copying anything onto it. In that mode BASH_SOURCE[0] is unset, so 7.20.0-4’s script printed “BASH_SOURCE[0]: unbound variable” and its last line named the caller’s working directory instead of the scripts’ one. It now names the fixed install path, /yuneta/development/yunetas/tools/sshd. The audit itself was never affected.

treedb file columns are seen, not only named (gobj-ui 7.23.159, 7.23.161, 7.23.166, 7.23.167)

kernel/js/gobj-ui -> 7.23.159, and the same range in the consumers. The form and the table showed a shortened sha256 and nothing else.

kernel/js/gobj-ui -> 7.23.163, and the same range in the consumers. The [N] of a hook cell opened a popup of every child id (5,675 for one device type), with no height, no scroll and no way out but a click inside it. It now opens the CHILD topic’s table filtered to the rows whose fkey names the row, with a chip and its clear button; a hook with several child topics asks which one. New events EV_OPEN_LINKED (output of the table view, declared by C_YUI_TREEDB_TOPICS), EV_FILTER_BY_PARENT, EV_CLEAR_PARENT_FILTER, EV_CHOOSE_LINKED. In 7.23.163 the table loads a hook as its count (hook_size), not as the ids of its children. Consumer keys filtered by, clear filter, choose a topic, show linked records.

The graphs take the wheel over their cards (gobj-ui 7.23.160, 7.23.164)

kernel/js/gobj-ui -> 7.23.164, and the same range in the consumers. The treedb schema graph (C_YUI_TREEDB_SCHEMA) gets the family’s toolbar camera cluster and wheel (it scrolls; Ctrl + wheel zooms). G6’s HTML nodes do not pass the wheel on, so over a card it did nothing; new yui_graph_forward_wheel() hands it to the canvas, used by the schema graph and, with a card selector, by the treedb graph (C_G6_NODES_TREE).

Back from a topic returns to the landing it came from (gobj-ui 7.23.165)

kernel/js/gobj-ui -> 7.23.165, and the same range in the consumers. After a click on a node of the schema landing, Back left the schema on screen under the cards url, and the landing toggle stopped working. Back now navigates to the landing’s own route.

7.20.0-4

A packaging revision, not a new version: since 7.20.0 the tree under kernel/, modules/, utils/ and yunos/ has changed only in two JavaScript submodule pointers and one document, none of which the packages carry. What changed is the packaging and tools/. The packages are rebuilt as yuneta-agent-7.20.0-4 and attached to the existing 7.20.0 tag.

sshd leaves the packages: tools/sshd/ scripts, run by hand

7.20.0-3 shipped /etc/ssh/sshd_config.d/10-yuneta-ssh-flood.conf in both packages, and the .rpm enabled fail2ban’s sshd jail. That is right on a node we operate and wrong on a node of another company: how sshd behaves is the operating-system policy of whoever runs the node, and a package that installs an agent has no business changing it. Neither package touches sshd any more. The same measures, and a few more, are scripts under tools/sshd/ (shipped in both packages at /yuneta/development/yunetas/tools/sshd/), run by an operator who decided to:

With sshd stopped on Debian, sshd -t fails for a missing /run/sshd (systemd removes it with the unit); every script creates it, as Debian’s init script does, and retries. Verified on wattyzer (OpenSSH 10.0p2): audit 0 FAIL / 0 WARN, the flood guard installed and in effect.

Upgrading from 7.20.0-3: the .rpm erases the drop-in and the jail (an edited one is kept as .rpmsave); dpkg leaves the drop-in in place as an obsolete conffile. Nodes where the drop-in was put by hand are not affected.

7.20.0-3

A packaging revision, not a new version: since 7.20.0 the tree under kernel/, modules/, utils/ and yunos/ has changed only in two JavaScript submodule pointers and one document, none of which the packages carry. The packages are rebuilt as yuneta-agent-7.20.0-3 and attached to the existing 7.20.0 tag.

Added: sshd stays reachable under a connection flood

Both packages install /etc/ssh/sshd_config.d/10-yuneta-ssh-flood.conf with LoginGraceTime 20 and MaxStartups 50:30:200 (stock: 120 s and 10:30:100). On 2026-09-13 an Internet-wide password botnet, 2,000-3,000 attempts an hour per node, filled sshd’s slots for unauthenticated connections, and past 10 of them sshd dropped new connections at random: up to 1,464 drops an hour on the yunovatios controller, the operator’s logins and the deploy tools’ rsync among them. Password login was already off, so the botnet could not get in; it only took the slots. With these two lines, applied by hand on three nodes that day, the drops went to zero.

Added: fail2ban watches sshd on RHEL/Rocky

The .rpm installs /etc/fail2ban/jail.d/yuneta-sshd.conf, [sshd] enabled with backend = systemd. Debian enables that jail in its own defaults-debian.conf; RHEL ships none enabled, so yunovatios-central had fail2ban running and nothing watching sshd. fail2ban-server requires python3-systemd on EL9. %post runs fail2ban-client -t before reloading fail2ban, with the same set-aside rule: a jail fail2ban cannot configure takes the whole server down, every other jail with it. It does not stop a botnet of thousands of addresses; it stops the one address that hammers.

7.20.0-2

A packaging revision, not a new version: the tree under kernel/, modules/, utils/ and yunos/ is the same one 7.20.0 was cut from. The packages are rebuilt as yuneta-agent-7.20.0-2 and attached to the existing 7.20.0 tag.

Fixed: the nightly log rotation reopens nginx on RHEL/Rocky too

The postrotate of /etc/logrotate.d/yuneta (the same drop-in in the .deb and the .rpm) guarded its USR1 to the web server’s master with if kill -0 "$pid" 2>/dev/null. On RHEL/Rocky logrotate runs in the SELinux domain logrotate_t, which may send the master USR1 but not the null signal: avc: denied { signull } ... scontext=logrotate_t tcontext=unconfined_service_t tclass=process, and the policy does not audit it (it shows only with semodule -DB). So the guard failed, the signal was skipped EVERY night without a word, logrotate.service ended successfully, and nginx went on writing to the rotated access.log.1 until the next restart of the web server. On yunovatios-central every rotated file ended at a deploy, never at midnight.

The guard now asks /proc/<pid>/comm whether the pid is a live nginx (stricter than kill -0: a recycled pid of another program no longer passes), and when there is no live master it says so in the logrotate output instead of skipping in silence. Verified inside logrotate_t, with the hardening of logrotate.service: the new drop-in reopens the logs. The drop-in is a conffile; a node that never edited it gets the new one on the next package.

v7.20.0 (2026-09-12)

with_link_events could only be set when a treedb’s C_NODE was created, so a client that follows the graph (the treedb graph of gobj-ui, any frontend) had to live with what the yuno was configured with: the parent’s EV_TREEDB_NODE_UPDATED on every link and unlink, from which it cannot tell which child moved, so it re-reads.

C_NODE has a new command, set-link-events, on the treedb service itself -- the same service a frontend already asks for nodes and descs:

ycommand -c 'command-yuno id=<id> service=<treedb> command=set-link-events set=1'

It acts at once, on the open treedb. It is either/or for EVERY subscriber of that treedb, not per client. Setting it needs the update permission. It is not persistent: on the next start the treedb has the configured with_link_events again (C_TREEDB’s attribute, copied to each treedb it opens). The change is logged (“with_link_events changed”, with the user).

C_NODE’s update-node with autolink used to run treedb_clean_node() (unlink EVERY link of the node) and then treedb_autolink() (link again from the record). Two consequences:

The new treedb_replace_links() (tr_treedb.h) compares each fkey column of the node with the same column of the record: a ref that is no longer named is unlinked, a new ref is linked, and a ref in both is not touched (no event, no write). A link that cannot be made or removed is logged and skipped, and the others go on. update-node still saves the record: a link can be repaired later, a lost record cannot.

A ref is now refused when its hook does not link the topic into the column where the ref arrived (“fkey reference: its hook does not link into this column”). _link_nodes() links by the hook the ref names, so such a ref used to be linked into ANOTHER column, without an error. A missing parent logs “fkey reference: parent node not found”.

No change: a fkey column that the record does not carry is still an EMPTY column for an autolink, and its links are removed (with the same “fkey empty” warning). treedb_clean_node() and treedb_autolink() stay as they were. test_c_node_link_events covers the four cases.

Treedb schema versions: published by whoever changes the schema, never invented

The model, as it always was meant to be: a schema is changed either from the C literal (raise the changed topic’s topic_version, and the treedb’s schema_version when the runtime must use it) or dynamically from an editor (gui_agent, ytreedb), which raises both on save. The runtime takes a version only if it is HIGHER than the one on disk. A literal behind a dynamic schema stays behind: the schema is now changed dynamically, and a new installation that must carry those changes takes them into the literal.

C_TREEDB’s projector had broken both halves since 2026-08-12 (98aa51bb8, 5889972f7), and it is restored:

test_c_treedb_system_schema follows the model: versions land verbatim, an untouched topic keeps its version, a literal behind an edit is not applied and one ahead of it is, and a column changed without raising its topic is not published (and diff-schema reports it). Stores that drifted under the old rule stay ahead of their literal until it passes them, or are reinstalled.

Persistent attributes: a failed save is no longer reported as saved

dbsimple.c (the Linux persistence behind gobj_save_persistent_attrs()):

The ESP32 persistence (esp_persistent.c) has the same defects and more; it is recorded in TODO.md, not changed.

impose_c_schema: the code takes the schema back

A new attribute of C_TREEDB (SDF_RD|SDF_PERSIST, default 1). With it on, open-treedb opens every treedb with its schema from C and neither reads nor writes __system__; against the disk, a stored schema_version or topic_version lower than the literal’s takes the literal, an equal one is kept, and a HIGHER one is overwritten with it — a dynamic change being reverted. __system__ keeps the changes, for diff-schema or to take them back. Turn it off to let the schema be changed dynamically (gui_agent, ytreedb); turn it on again to impose the code — somebody lost that permission, or the system was broken or changed by mistake — and restart the yuno.

test_c_treedb_system_schema gets a realm of its own (inside the directory it wipes) so a persistent attribute can be saved, opens __system__ with the flag off, and checks imposing: the disk comes back to the literal’s schema and columns while __system__ keeps the edits. treedb_schema_fidelity opens with the flag off too.

The agent’s schema marks realms as its main topic

treedb_schema_yuneta_agent.c: realms carries 'main_topic': true, published the way any change to a topic is: its topic_version goes 7 -> 8 and the treedb’s schema_version 23 -> 24. Nothing changes in what a viewer draws: realms is the only topic of the agent’s treedb hooked to itself, so the graph already deduced it. The mark is there as the reference example of the key. YUNO_TREEDB.md §3.2 / §3.11 and the treedb_open_db() notes show it.

The treedb graph’s find box only looks (gobj-ui 7.23.158)

kernel/js/gobj-ui -> 7.23.158, and the same range in the consumers. The find box searched the whole treedb and unfolded a page of the matches it found, so every keystroke re-laid the graph out and moved the camera, and the groups it opened stayed open when the box was cleared. Now it lights the matching cards ON SCREEN and changes nothing else; emptying the box leaves the graph as it was. Opening up to a topic stays with the legend’s focus button. The term stays live across unfolds and refreshes, Enter / Shift+Enter centre the next / previous lit card (k/N matches), and the count adds what it did not light (+N not shown, +M in hidden topics). Consumer keys not shown, find on screen.

The treedb graph: a layout stepper, one icon shape per meaning, elbow edges (gobj-ui 7.23.157)

kernel/js/gobj-ui -> 7.23.157, and the same range in the consumers:

The JSON viewer compares two documents, and keeps them (gobj-ui 7.23.144)

kernel/js/gobj-ui -> 7.23.144: C_YUI_JSON_PAD gets a second pane on demand and a compare that shows the differences of the two documents -- one row per id of the flat form (json2flat / flat_diff), added / removed / changed -- in place of the viewers. The pasted texts and the layout are kept in localStorage (storage_key), so the pad opens as it was left. gui_agent 0.22.61 and gui_treedb 0.17.31 take it with the new keys; the gobj-ui demo (demo.yuneta.io, niyamaka.com) opens it from its top toolbar too.

gui_treedb 0.17.32: its About no longer lists the connections -- the Diagnostics table keeps the deployment identity and the session.

gui_agent 0.22.62: “For TreeDB” names a production yuno after its production node. A local copy carrying the production names resolved to the same url, and the export kept the first row -- the development machine, first by host.

gui_treedb 0.17.33: Connections has one row per connection (no service rows) and its checkbox marks a connection; the Topics / Graphs pickers list only the connections connected or marked, each with every service it discovered, and the transport asks for all of them in its identity card.

A JSON viewer in every SPA, node lists by name (gobj-ui 7.23.143)

kernel/js/gobj-ui -> 7.23.143, and the same range in the consumers: a JSON viewer in the account menu of every SPA -- paste JSON from outside and read it with the library’s viewer (setup_json_pad / C_YUI_JSON_PAD) -- and a treedb topic table that opens sorted by id. yunos-js: the node lists of gui_agent are alphabetical, and gui_treedb’s About gains the Diagnostics table gui_agent has.

Then, on the key:value rule (the key of a data record is id, the rest is value): gui_agent’s node rows carry their key in id and its trees sort by it (0.22.59); the “For TreeDB” connections document gives every record an id (<node>^<yuno_id>) and a role^name label, and gui_treedb keeps that id on import and recognises a connection by it (gui_agent 0.22.60, gui_treedb 0.17.30).

create-config says a __version__ must be a string

A config carrying "__version__": 1 was refused with “Configuration version is required” -- for a file that plainly has one: kw_get_str() gives the default for a number (and logs “path MUST BE a json str”, which names the cause). The answer now says “Configuration version is required, as a string”, with the yuno identity in front. Found when yunetas sync-configs picked up a data file of yunovatios’ batches (renamed there with the _ prefix of the other data files).

v7.19.0 (2026-09-10)

The main topic of a treedb: hierarchical, and markable in the schema (gobj-ui 7.23.142)

Back to the treedb graph with its focus (gobj-ui 7.23.140)

kernel/js/gobj-ui -> 7.23.140, and the same range in the five consumers. The topics view’s graph button returns to the graph as it was left, its focused topic included, through a new shell helper, yui_shell_last_route_under() (a page-lifetime mirror of the routes visited). A node’s child spec can now declare remember_position, so a config can make a treedb node’s tabs point at where each workspace was left -- yunovatios turns it on for its treedb nodes.

The treedb topic cards lose the graph icon and gain the topic’s shape (gobj-ui 7.23.139)

kernel/js/gobj-ui -> 7.23.139, and the same range in the five consumers. A card’s graph icon always entered the graph focused on that topic, over what the reader had left there; the toolbar’s graph button is the way in now. The card shows instead the topic’s version, its number of columns, the topics it hangs from and the ones that hang from it -- from the desc, no request.

The treedb topics view opens the graph with no focus (gobj-ui 7.23.138)

kernel/js/gobj-ui -> 7.23.138, and the same range in the five consumers. A graph button left of raw json opens the whole treedb as a graph with no topic highlighted -- the only way in used to be a card’s graph icon, which always focuses that card’s topic. Derived from the host’s card route template, so gui_treedb, gui_agent and yunovatios get it with no change.

The treedb graph: a toolbar in three parts, and never a blank view (gobj-ui 7.23.137)

kernel/js/gobj-ui -> 7.23.137, and the same range in the five consumers.

A treedb topic keeps its colour (gobj-ui 7.23.136)

kernel/js/gobj-ui -> 7.23.136, and the same range in the five consumers. The topic palette of the treedb graph and of the schema diagram goes by alphabetical order of the topic names instead of the backend’s order, which varies from load to load, so two topics no longer swap colours. Every topic whose colour nobody chose may change colour once with this release; a chosen colour is saved and not touched.

The treedb graph legend keeps its order (gobj-ui 7.23.135)

kernel/js/gobj-ui -> 7.23.135, and the same range in the five consumers. The legend’s topic chips are in alphabetical order, always: starring a topic as the main one no longer moves the strip, and the backend’s order -- not the same from one load to the next -- no longer decides where a chip sits.

The treedb graph: reset to default, from every menu (gobj-ui 7.23.134)

kernel/js/gobj-ui -> 7.23.134, and the same range in the five consumers.

The treedb graph: one outline, no browser menu, a way back (gobj-ui 7.23.133)

kernel/js/gobj-ui -> 7.23.133, and the same range in the five consumers.

The rule, written down (gobj-ui 7.23.132)

The sweep of the last sections became a norm, in the place each reader already looks: CLAUDE.md here (JS GUI conventions), gobj-ui’s README (Conventions) and CLAUDE.md, and the CLAUDE.md of yunos/js, wattyzer and yunovatios.

EVERY control carries a title AND an aria-label, and both are translatable. No exceptions.

A control is any input, select, textarea, button or anything that behaves as one; all four attributes are written where the control is built. It is a floor, not a preference — the whole ecosystem was measured against it, on the deployed page, and the target is zero controls without a name.

The rule carries with it the list of what LOOKS like a name and is not: a <label> beside the control (Bulma’s field), a <label for=x> over a control that carries only name=x, a placeholder, and the visible text when it hides on mobile or says the STATE rather than the action — plus the one shape that IS enough, a <label> that wraps its control, and the one that is worse than nothing, a LITERAL aria-label beside a visible i18n label, which overrides the translated text for a reader.

And the two things no attribute reaches: what a WIDGET draws for itself, named after the render and again on every rebuild; and an <option>, which is text like any other. Ending with the check, which is the part a rule usually forgets to say: not a grep — dump title/aria-label from the DEPLOYED DOM and switch language.

The frontend view, and everything that was left (gobj-ui 7.23.131)

kernel/js/gobj-ui -> 7.23.131, and the same range in the five consumers.

The last windows nobody had dumped: the frontend view, the site map, about, preferences, the five login screens, the overlay layer (a toast and each of the three confirm shapes), and the yunovatios controlador, the one SPA that had never been read at all.

One defect, and it is a shape the sweep had not met: an <option>. The frontend view’s eight layout labels were written into its LAYOUTS table and put straight on the node, so a language change renamed the select (“disposición”) and left its options reading “Vertical compact”, “Lanes vertical”, “Dagre (top → bottom)”. An <option> is text like any other: it carries data-i18n now, with value kept explicit — a translated option with no value tells the FSM to enter a layout called “Vertical compacta”, which is the trap 7.23.13 already paid for once. And the keys are SHARED with the JSON graph’s own picker (vertical tree, dagre top-down, dagre left-right): the two graphs sit side by side in the same console, so the same layout is called the same thing in both — the toolbar-vocabulary rule, applied to a select.

In wattyzer’s login, the three quick-control aria-labels went through t() with no key on the element, and paint_i18n made up for it with a hand-written list of three selectors. It works, and it is a list somebody has to remember to extend the next time a button lands in that header — so the keys go on the elements and the repaint walks the attribute, the way the other three SPAs already did.

Read back: zero controls without a name in any of them. What still does not change language is data (family names, a place, a fake schema’s fields), words that are the same in both languages (“No”, “I18n”, “Topics”, “Editor”) and readouts (“19 px”).

The Developer window read in English, and the guard could not see it (gobj-ui 7.23.130)

kernel/js/gobj-ui -> 7.23.130, and the vocabulary added in yunos/js (both SPAs), with accent fixes in wattyzer and the yunovatios GUIs.

The dump pointed at the Developer window. In gui_agent and gui_treedb, 22 of its 32 elements never changed language: the trace chips, the group labels, the view segments, the output titles. The library’s markup was already right — it has carried the keys on the DOM since 7.23.113. What was missing was the vocabulary: 26 keys neither yuno defined, so i18next answered each with the key itself and the whole window read in lower-case English beside a Spanish shell.

And it is a blind spot of validate-locales — a wide one. TRACE_DEFS, the grp / mk_view / mk_expand / mk_dir / mk_out helpers and the OUT_TITLES lookup all pass their key as a VARIABLE, so a scan of t("…") sees none of the 46 keys this window asks for and reports OK with the window entirely untranslated. The library lists them above TRACE_DEFS now, for a consumer to copy — and the first version of that list was itself incomplete (33 of 46), which the next dump said out loud: collecting by hand misses the helpers you forgot you wrote. The list is a convenience; the dump is the check.

Along the way, four Spanish words that had lost their accent — Creacion, Automata, Trafico, Periodico — and three “Solo” that need one. They came from wattyzer’s bundle, where these translations were first written, and travelled into every consumer that copied them.

Read back on all five deployed sites: 1 of 32, and it is “I18n”, the same word in both languages.

The demo had no guard, and it is the app that mounts everything (gobj-ui 7.23.128 - 7.23.129)

kernel/js/gobj-ui -> 7.23.129, and the same range in the five consumers.

The dump run against demo.yuneta.io. It is the richest surface in the ecosystem -- every gadget of the library is mounted in one page -- and the only consumer with no validate-locales. It came back with 61 controls with no name and 40+ names that never change language, and most of what it found lives in the LIBRARY, so it was true in all six apps at once.

Sixteen literal aria-labels on the library’s own widgets (7.23.129): the pager’s back and discard, a window’s minimize / maximize / close, the dock’s close, the breadcrumb nav, the wizard’s back and next, the file field’s choose / remove, the toast’s ✕, the modal’s back and ✕, the confirm’s ✕. Every one of them is icon-only, so the aria-label IS the name — and every one was frozen English everywhere. The demo says it in one line: the wizard’s back button read “Atrás” and announced itself as “back”.

Two more shapes from the same dump: Tom Select HIDES the <select> it is given and draws its own box in front of it, so naming the original named the element nobody can reach (7.23.128); and the topic table’s row icons — edit and delete — had no name at all, written as an HTML string inside a formatter, the one place a sweep of createElement2 specs cannot look. Three search boxes had a placeholder as their only name, which vanishes the moment something is typed; one of them composed it (t('coordinates') + '...'), so no key could reach it.

In the demo: a validate-locales of its own, adapted to its inverted convention (English is the source, so keys are English prose and only the es bundle is checked), wired into its build. It found 100+ keys the library asks for and the demo never translated, and three duplicates — one of which was a real COLLISION: window is the library’s label for the dev window’s output chip AND was the demo’s noun for its demo windows; the later one won, so the chip read “ventana” in lower case. Its table was also a Tabulator with no locale at all, which is worth fixing where a consumer copies the recipe from.

Two blind spots of the guard closed on the way: a key can arrive through a local ALIAS of t() — yui_tabulator_i18n.js asks for its whole chrome through tr(key, default), so a scan of t( saw none of it and the paginator sat in English with the guard reporting OK — and a key of the demo can carry an escaped quote, where [^"]+ handed back half a key and demanded a translation that was already there.

Read back at the end: zero controls without a name across sixteen chapters, and what still does not change language is the fake schema’s field names and the graph legend’s topics — data, not prose.

The same dump on the two yunos (gobj-ui 7.23.123 - 7.23.127)

kernel/js/gobj-ui -> 7.23.127, yunos/js -> both SPAs on it, and the same range in wattyzer and the two yunovatios GUIs.

The deployed-DOM dump of the previous section, run against gui_agent and gui_treedb. Zero controls without a name in either -- the shapes the sweep chased are gone from this layer -- and two defects that only a dump finds, both of them about a name that is right once and wrong afterwards.

The shell declaration held its keys in Title-Case English. app_config.json carries i18n keys as DATA, and nine of them in each app were written as English prose. i18next answers an unknown key with the key itself, so four toolbar buttons of each app announced themselves as “Toggle language”, “Account menu”, “Agent Console home” and “Toggle dark theme” in a Spanish session, and never changed. They are lower-case keys now -- the convention the locale files state in their own header -- and both locales define them. validate-locales reads the file in both yunos as well now, and collects a value only when it LOOKS like a key: one that does not is a literal the config carries on purpose (ES/EN on the language button, the way 19 px is a readout and not prose).

Tabulator’s row-selection checkbox said “Select Row” in every language, and getting that right took four attempts, each one of which the dump refuted:

That last chain is the lesson of the section: a name put on a widget’s DOM is not a fact, it is a race with the next render, and the only way to know which one won is to read the page after the renders that matter -- a language change above all.

A name for every control, read back from the deployed DOM (gobj-js 7.16.6, gobj-ui 7.23.121 - 7.23.122)

kernel/js/gobj-js -> 7.16.6, kernel/js/gobj-ui -> 7.23.122, yunos/js -> both SPAs on those two, and the same in wattyzer and the two yunovatios GUIs.

The tooltip sweep of the previous section ended on the windows nobody had looked at yet -- devices, and whatever was left. The method changed for this last stretch, and that is the part worth keeping: a static sweep names the SHAPES of the defect, but only the deployed DOM says which controls a reader can actually name. Dump every input/select/textarea/button of a window, resolve its accessible name the way a reader does (aria-label, then label[for], then a wrapping <label>, then the text, then title, then placeholder), switch language, and diff. Two things fall out of that dump that no source scan produces: a control with NO name, and a name that does not change when the language does.

In the framework, one line and it explains a class of bugs: refresh_language() looks its four attributes up with querySelectorAll, which searches DESCENDANTS and never returns the node it is called on. So a caller that hands it the very element carrying the key got the children translated and that element’s own attribute left in the source language -- invisible in English, where the key IS the text. It bites where a widget is built lazily and translated as a unit: the toolbar’s language dropdown opened as role="menu" aria-label="select language" beside its own trigger reading “Elegir idioma”.

In the library (7.23.122): yui_toolbar()'s scroll arrows shipped their raw i18n KEY, waiting for the host to repaint them -- but a view’s toolbar is REBUILT on every action it carries, and a host that translates its tree once at mount never sees the new arrows. A deployed Spanish map offered “scroll left”. They go through t() where they are built now, and keep their keys so a language change still reaches them.

In wattyzer: 24 controls with no name at all, and 17 literal aria-labels. A Bulma field puts the <label> BESIDE the control, with no for and no wrapping, so it names the box for the eye and for nothing else; the name goes on the control itself, from the label’s own KEY, so the two cannot drift. Fifteen of the literals sat on buttons that ALSO carry a visible i18n label, where the literal overrides the translated text for a reader. Two more findings came from the dump alone: a language switch never reached an OPEN modal (every modal of that app is appended to the body, and the switch repainted only the shell’s container), and the brand announced itself as “wattyzer go to root” in both languages -- app_config.json holds i18n keys in DATA and nothing asked for them, so validate-locales reads it now, the way the yunovatios copy already did.

In yunovatios: the two census selects of the device card and the select-all checkbox of the controllers view had no name; and maplibre labels its own chrome in English, never through the app’s i18next, so zoom, compass, fullscreen, geolocate and the attribution toggle all read English on a Spanish map. The map takes locale: yui_maplibre_locale(t) at construction -- maplibre reads it once, when each control builds its DOM -- and yui_maplibre_relocalize() on a language change, which is the half a locale alone cannot do. Same open-dialog fix as wattyzer’s.

Read back at the end: zero controls without a name across eight wattyzer windows and seven of central; what still does not change language there is treedb DATA (family names, a place), which is right.

A contrast sweep of the whole GUI, measured (gobj-ui 7.23.84 - 7.23.120)

kernel/js/gobj-ui -> 7.23.120, yunos/js -> both SPAs on ^7.23.120, and the same range in wattyzer and the two yunovatios GUIs. It began as a review of the treedb graph round and became a sweep of every surface the library draws, with getComputedStyle in a browser and not an eye: a ratio ends an argument, “it looks washed out” does not.

The review of the graph round turned up five things (7.23.84): the legend’s chip title carried no i18n key while the strip is only redrawn on EV_LEGEND_STATE; the engine took the language change from a raw i18next listener instead of the shell’s event; the node, edge and port popovers left their PREVIEW standing when dismissed with anything but cancel, and the next Save wrote it; the wheel’s two keys named different gestures, so Cmd + wheel did nothing on a Mac; and a port was hit by a radius although it can be a square or a diamond. Then 7.23.85: the colour applied to a CARD lived on the live style alone, which on an html card sits under the html -- the first repaint rebuilt it from the topic’s colour and nothing ever reached __graphs__.

The measured part, surface by surface:

Seven options of C_G6_NODES_TREE that no host could reach were forwarded on the way (7.23.93), because the view that creates the engine is what makes an option exist.

And the last thing it left open, closed (7.23.98): connected / disconnected was told apart by green vs red alone, in the map’s circles as in its labels -- the one pair colour blindness does not read, and in greyscale two discs are the same disc. A device that is down carries an exclamation mark, and a cluster with something down carries it inside its own count (4 !). It rides in the count’s text-field and not in a second symbol layer on the same point: that is a placement question, and the badge lost it -- with the filter removed as well, which is how the placement was told from the expression. Verified on both cases, the negative one included: with all four devices connected the cluster reads 4 in green.

One more the sweep did not reach (7.23.99): the legend’s star for a main topic the reader CHOSE. Its button carries pressed_state, which inverts the ground, while the glyph kept the gold set INLINE -- and an inline colour beats the ink the pressed rule sets with the fill. That one star sat on the scheme’s own text colour: 1.06:1 in dark, ~2:1 in light, while the four stars merely OFFERING to become the main one were bright. It survived the sweep because only a CHOSEN main is drawn pressed, and main_topic is a per-treedb per-user preference -- the same strip reads right wherever nobody has picked one.

Making the glyph inherit the pressed ink fixed the ratio and cost the COLOUR, which is what the star says -- so 7.23.100 stops drawing that star pressed at all: it keeps its gold and wears the ring the focused chip already wears (box-shadow: inset, because a chip in a has-addons group clips a shadow outside its box). The strip now says the two things separately: gold star = this is the main topic, ring around it = a reader CHOSE it, and pressing hands the choice back to the graph.

And asking why that star was not simply wearing Bulma’s .button.is-active turned up the answer to a different question (7.23.101): the library had three spellings for “this toggle is ON”. The reason was where the rule lived -- pressed_state sat in lib_graph.css, which reaches an app only if the app mounts a graph, because a stylesheet rides its JS import. So the JSON viewer’s view switch had grown a --bulma-link fill of its own (a STATE wearing a colour that names a kind of ACTION), the gclass viewer’s two switches wore is-active with no rule behind them anywhere (1.3:1), and yunovatios kept a copy of the rule under a third name, is-pressed, with the reason written in its app.css. The rule and set_pressed_state() move to yui_toolbar.js / .css, where a toolbar’s toggle belongs (lib_graph.js re-exports the helper); every one of those sites now says it the same way. Bulma’s own is-active, measured, is a 10-point lightness shift -- 1.27:1 light, 1.33:1 dark, against 9.44:1 / 8.46:1 -- and it shares its declaration with :active, the look of a finger down right now.

7.23.102 finishes it in C_YUI_PERIOD, the last place a STATE wore a colour: the granularity in use and the picked calendar cell were an is-link fill and are pressed now. The overflow granularities keep is-active and that is the RIGHT call -- those are dropdown-item links in a menu, where it is Bulma’s own mark for the current item; the button standing in for them looks pressed like any other button. ARIA follows the element rather than the look: aria-pressed on the strip’s buttons, aria-current on the menu item, aria-current="date" on the picked cell -- the widget carried none of the three.

And then the unification turned out to have unified one thing too many (7.23.103): a TOGGLE and a SELECTOR are not the same control, and the pressed pill was answering a question only one of them asks. A toggle is on or off -- no which one -- and keeps the neutral pill. A selector picks ONE of N, and there the eye reads a row of identical grey buttons by HUE, not by comparing shades, so the chosen segment is FILLED with the link colour (selected_state, beside the pressed pair in the toolbar module). Measured against the button’s own ground the fill is 5.14:1 in light and 3.53:1 in dark -- less luminance than the pill (9.44 / 8.46) and a different hue, which is what says this one, of these. Filled: the graph’s three node views, the JSON viewer’s three, the gclass viewer’s two switches, the period’s granularities and its picked calendar cell, and the map’s three modes in yunovatios. Still pressed, because they are toggles: node labels, the legend’s loose and focus buttons, the graph’s anchor and its selection mode, the alarms view’s active only.

And the graph’s camera belongs to the reader (7.23.104). A refresh, a change of node mode and a new main topic each asked for a full refit, and a refit throws away the one thing the reader had decided: those three rebuild the CONTENT, and content moving is no reason to move the reader. They hold a NODE at its pixel now, with the pair the folds already used (yui_graph_viewport_of / yui_graph_place_at) -- which keeps the zoom, because it only translates, and survives a relayout that moves everything. It also survives a RELOAD: C_G6_NODES_TREE takes a camera attr and publishes EV_CAMERA_CHANGED {zoom, x, y} when a move settles (700 ms of the browser’s timer -- a wheel notch, a pinch and a drag each fire aftertransform many times, and what is worth saving is where the gesture ENDED), and C_YUI_TREEDB_GRAPH persists it under the view’s name like main_topic. It took two more to actually do it, and both are worth keeping. 7.23.104 brought the ZOOM back and put the graph somewhere else, because it saved G6’s getPosition() and replayed it with translateTo() — which are each other’s inverse only at zoom 1, a trap this library had already paid for once and written down (yui_graph_place_at() exists for it). A camera is the zoom, a NODE and the viewport pixel that node was on, restored with that helper — the same thing the folds keep across a rebuild. Then 7.23.106: the VIEW rebuilt the payload as {zoom, x, y} on its way to the store, dropping the node, so the engine refused its own camera back. Measured on the deployed gui_treedb, with a pan so the framing is nobody’s default: the same card at x=337 y=152 before and after F5, at 121%. The lesson for the next one is the checking, not the arithmetic — 7.23.104 was called verified on a zoom readout alone, and the zoom was the half that worked.

And the graph that looked out of focus was one missing border (7.23.107). Two reports, one cause: a card, a pill and a figure are one record drawn at three sizes, and they were speaking two colour languages. The card is a TINT of the topic colour with the vivid colour around it; the figure of shape mode was the raw colour filled, ringed by getStrokeColor() — the same colour darkened 20%, which measures 1.58:1 against its own fill. Not a border, a ramp; and twenty-five of them in a fan is what reads as a blurred picture. The figure wears the card’s paint now: 4.67:1 fill to rim, 12.08:1 rim to canvas, at lineWidth: 2 — checked in the rendered pixels, where the rim is a 2px band with one antialiased pixel either side. The colour was never lost, by the way: the figure’s fill measured exactly the legend chip’s RGB. What differed was the treatment.

A tranger record is a DOCUMENT and is read with the viewer now. Its dialog showed a <pre> with a Copy button under it -- a jwt payload with its roles and its allowed origins read by scrolling text -- while C_YUI_JSON sat one gclass away, already hosted by that same view for the raw tranger. It gets the whole record, so it never asks for a subtree; one viewer at a time, destroyed with the dialog (a viewer left alive holds its service NAME and the next record finds it taken). 7.23.108 gives the shell’s dialog a wide option for the ones that hold a document: 640px is a width for a question with two buttons, and at that width the viewer wrapped every long value and pushed its own view switch behind the toolbar’s arrow.

And the wheel means one thing now, in all three graphs (7.23.109). C_G6_NODES_TREE has scrolled on the wheel since 7.23.75 while C_YUI_JSON_GRAPH and C_YUI_GOBJ_TREE_JS went on zooming with it — the same gesture with two meanings in graphs a reader has open side by side, and the JSON viewer is reached from inside the other two. The pair moves to yui_graph_camera.js, where the rest of the camera vocabulary already lives, with its two traps beside it: the scroll’s enable and the zoom’s trigger must name the SAME key (a G6 trigger is a CHORD), and the zoom carries animation: false, which the treedb graph was the only one missing. Measured in the demo on all three: a plain wheel leaves the zoom untouched and moves the drawing, Ctrl + wheel goes 100% → 150%.

The MAP too (7.23.110): maplibre’s cooperativeGestures, which never blocks a wheel carrying ctrlKey — so the trackpad pinch survives — and whose own notice teaches the gesture, which a graph has to do without. It is set where the map is BUILT and not in the attr’s default value, and that is the part worth remembering: a JSON attr is replaced WHOLESALE by a host that passes its own, so a default is a suggestion. The demo passes map_settings and never saw the first version of the change; the same trap the SDK documents for a crypto override.

And with the gesture, maplibre’s own words (7.23.111): its zoom tooltips, geolocate button, attribution toggle, popup close and that very notice were English inside a Spanish app. yui_maplibre_locale(t) gives the map its locale at construction and yui_maplibre_relocalize(map, t) does the half a locale cannot — maplibre reads its strings ONCE, when each control builds its DOM, so a language change has to rewrite what is drawn. The notice is also HELD for 2s: maplibre shows it 100ms and fades it for a second more, and this GUI has no transitions, so what was left was a blink — worse than no notice. That recipe was already solved in yunovatios’ yv_map_base.js, which is where it was found; the library learnt it instead of inventing a second one, and the app keeps its own.

And then the rest of the tooltips (7.23.112), swept for the two defects that make one: a title written as a literal, and a title: t(…) with no data-i18n-title — translated once and frozen for the life of the view. Four real ones out of 51 hits: the FORM’s toolbar (save, undo, clear, copy, paste), where the visible label carried its key and the title beside it was raw English — so what a pointer reads and what a screen reader announces were the untranslated half; the tab CLOSE of C_YUI_NAV, the same shape; the map’s own three controls, which read their keys once and showed maplibre.drag_mark itself wherever a consumer had not defined them; and the treedb table’s operations column, titled 'Op', which takes a titleFormatter now because a language change re-runs setColumns() over the SAME definitions. The other 47 are not defects, and knowing why is the useful half: a title: handed to a modal or a window is an i18n KEY the helper translates, and the G6 plugin toolbar is rebuilt whole on a language change.

And the Developer window, which had no i18n at all (7.23.113): its ~30 strings — the trace chips, the view and output selectors, the direction filters, the search placeholder, copy and clear, the muted row and the window’s own title — were English literals. A debugging tool is still a tool somebody reads. Three details make it RE-translate rather than translate once: TRACE_DEFS carries the i18n KEY where it carried the label, because refresh_dev_chrome() repaints those chips from data-label on every toggle; the two COMPOSED titles are gone, since 'Show ' + label + '…' is a string that is no key; and the ⊘ Periodic chip is a glyph span plus a labelled span, because refresh_language() replaces the FIRST text node of the element carrying data-i18n and would have eaten the glyph with the word.

And the schema editor’s forms had no NAMES (7.23.114, 7.23.115): field() draws Bulma’s shape, where the <label> is a SIBLING of the control with no for and no wrapping — so it named the box for the eye and for nothing else, and a reader announced every input of the column and topic forms as unlabelled. The name is put on the control from the label’s own key, in field() and not field by field. It took two goes: the first named the outer tag, and select_input() returns Bulma’s <div class="select"><select>, so every SELECT stayed anonymous while the inputs beside them were fixed — measured on a deployed schema. It descends now. The flag checkboxes are NOT a defect: each sits inside its own <label>, which IS its name.

And the same defect in disguise, in C_YUI_FORM (7.23.116): there the label IS written <label for={name}>, which reads like a correct association — except for matches an id, and no control of that form sets one; they carry name. So every field of every form the library builds (the treedb record editor, wattyzer’s, yunovatios’) was unlabelled for anything that is not an eye. The name goes on the control from the label’s own key, in the one place a field is finished — not as an id, because two forms can be open at once and duplicate ids would break both.

The graphs window closed it (7.23.117, 7.23.118), and the last two came from READING the deployed toolbar rather than the source: the treedb graph’s two selects had no name — their rótulo is a <span>, and an is-hidden-mobile one, so on a phone the control says only its current value (the other three graphs already named theirs); its refresh button is an icon plus an is-hidden-mobile label and nothing else; and the toolbar’s own scroll arrows said scroll left in English beside a Spanish toolbar, because yui_toolbar.js asks for those keys and NO consumer had defined them. That last one is worth keeping: a key asked for by a module the app does not import DIRECTLY can slip past validate-locales, which is how two of them stayed missing in five apps at once.

And the last one is Tabulator’s own DOM (7.23.119, 7.23.120): it draws one filter box per filterable column and gives it NOTHING — no label, no aria-label, no placeholder — so the alarms window had five anonymous text boxes under Equipo / Nombre / Alarma / Estado / Descripción. The name is composed from the column’s title, which costs one consumer key with an interpolation instead of one per column, and it runs on a language change too. It took two goes: the obvious key name, filter column, ALREADY existed in two apps as that box’s placeholder and carries no interpolation — so the first version named all five boxes the same, which the deployed read caught.

Three views of a record, the tree reads down, the wheel scrolls (gobj-ui 7.23.75 - 7.23.83)

kernel/js/gobj-ui -> 7.23.83 and yunos/js -> both SPAs on ^7.23.83. Asked for together, all in the service of one graph with many nodes.

The wheel SCROLLS the graph; Ctrl + wheel zooms, in every operation mode of every graph on C_G6_NODES_TREE. A wheel that zoomed made a graph taller than the screen a thing to be looked at from afar or read through a keyhole -- never scrolled, which is what a wheel does on a map and on every page. Shift + wheel goes sideways, a trackpad pinch arrives as Ctrl + wheel, the touch pinch is untouched.

The treedb tree reads DOWN. Read right it was dagre with the siblings held still, and nobody could tell the two apart; down is where a tree has room. The algorithm is written once and the top-down tree is the same tree fed transposed cards and read back transposed. treedb-outline is gone -- a list that indents is a JSON viewer, and the library has one.

A record has two SHAPES. Open is the card with its ports, the only shape a link can be edited on; closed is a rounded square of the topic’s colour, no ports, no text -- the topology alone, as a native G6 rect so focus, selection and anchor paint on its own stroke. Two persisted toggles in the view’s toolbar (node_mode, node_labels), and one node against the rule by double click or its context menu. The ports of an open card are handles now (radius 10 / 5, 2 px). Consumer i18n keys: closed nodes, node labels, open node, close node; treedb-outline dropped.

7.23.76 is the one that shipped: 7.23.75 left the outline’s case in the view’s option_label(), and every consumer’s validate-locales refused the build for a key asked for and defined in no locale -- the guard doing its job before a deploy. 7.23.77 shrinks the +N chip beside closed nodes (40×22, following the shape of the card it continues), and gives the test-app’s graph a page of one so a chip shows. 7.23.78: expand all and collapse all leave the ZOOM alone -- both fitted the whole graph, so opening everything zoomed out to a strip and closing it zoomed in on the roots; the camera holds the anchor still, else the first root, as a single fold does. 7.23.79: the default ports go to radius 14 / 8, because on a deployed treedb 10 / 5 still read as dots -- and 7.23.80 is why they did: Save wrote the SIZE and the port radius of every card into __graphs__, chosen or not, so every Save froze the library’s size of the day and no later default reached a saved treedb. A size on the tier’s default is not saved any more, a closed node saves no size at all, and the node’s context menu (edition) gets reset sizes / reset topic sizes, which forget every saved size and put the defaults back on the spot. Consumer i18n keys reset sizes, reset topic sizes.

A port has a SHAPE, and its own properties popover (7.23.81). G6 draws every port as a circle, so a record’s card is now a node of its own, treedb-card -- G6’s html node with drawPortShapes overridden -- where a port’s shape is circle, square, diamond or triangle; r stays the one size. A selected port shows a gear beside it, as the node does, and the port context menu has the same entry for a finger: shape, radius and scope (this port / the same port of every card of the topic / every port), with a live preview. Remembered as the topic’s default and saved per node in __graphs__ (port_shapes). The node and edge popovers’ labels had no i18n key at all and rendered in English in every language; keys added to the consumers with the port’s.

And a record has THREE views, not two (7.23.82), the same three the gobj tree view offers: expanded is the card with its pills and ports; compact is a one-line PILL with the name inside and small ports, so a link can still be drawn; shape is the FIGURE of the topic’s colour, no ports, no text -- and the figure is chosen now, square, circle, diamond, triangle, hexagon or star, in the node properties popover, remembered as the topic’s default and saved per node as node_shape. The three views are three push buttons in one group (7.23.83: a view is picked at a glance, and the pressed one says which is on); the labels toggle is enabled on figures only. Consumer i18n keys nodes, full, compact, expand node, collapse node, hexagon, star; closed nodes, open node, close node gone.

The focused legend chip is highlighted, not pressed (gobj-ui 7.23.74)

kernel/js/gobj-ui -> 7.23.74 and yunos/js -> both SPAs on ^7.23.74. Two loose ends of the 7.23.73 review, found reviewing the review.

The body of a focused chip wore a state it does not own. The body is the show/hide toggle of its topic, and that is what its aria-pressed says; the focus belongs to the crosshair next to it, which is the button that looks pressed. 7.23.73 painted the body with pressed_state as well, so the eye and a screen reader read two different states off one button -- and the count, has-text-grey by Bulma’s !important, sat grey on the near-black of the pressed look, at 2.7:1 against the 4.5:1 a text needs. The focused chip now carries an inset ring (GRAPH_LEGEND_FOCUSED) and repaints nothing in it. The header of treedb_layout.js also said two layouts and two adapters; there are three of each since 7.23.70.

A review of the graph and authz rounds (gobj-ui 7.23.73)

kernel/js/gobj-ui -> 7.23.73 and yunos/js -> both SPAs on ^7.23.73. Three findings of a review of the 7.23.66-7.23.72 round; the backend half of the same review lives in the yunovatios repo.

The treedb layouts died on a deep tree. layout_tree, layout_radial and layout_outline walked the spanning tree with recursion -- the natural way to write it, and the one the data breaks: a self-referent hook (a place inside a place inside a place) is as deep as the store says, and the stack is not. A chain of 20000 nodes answered RangeError: Maximum call stack size exceeded, and a graph that cannot be drawn is not a layout choice, it is an exception in the console. The five walks share one walk_order() now -- parent before child, so it reads forwards for a pre-order pass and backwards for a post-order one -- and the new test was first made to FAIL on the old code, which is the only way a regression test is worth having.

A state was painted with a colour of the palette. The focused chip, the focused crosshair, the shown loose records and the chosen main topic each paired a STATE with is-primary/is-warning -- exactly what set_pressed_state() was written against in 7.23.12, where every colour of that palette names a KIND of action, so a state wearing one reads as a category. Same lesson, the other strip.

And the legend said its topic names at 0.75rem. 7.23.69 raised the GLYPHS of the chips out of is-small and left the name and the count behind, the name being the thing a layer control is read for. The button stays small so the strip keeps its height; the label comes up inside it, the way the glyphs did.

A treedb form could not SAVE, and a user could not be given a role (gobj-ui 7.23.71 + 7.23.72)

kernel/js/gobj-ui -> 7.23.72 and yunos/js -> gui_treedb 0.17.28 / gui_agent 0.22.57, both on ^7.23.72. Two defects of the same form, found one behind the other on the deployed console.

An fkey is edited by LINKING, and writable does not govern it (7.23.71). 7.23.55 fixed a real bug -- a <select> ignores readonly, so a column without writable rendered an EDITABLE select -- by disabling what the attribute cannot reach, and it caught the fkey with it. An fkey is normally declared with no writable flag at all (treedb_authzs’s users.roles is ['fkey']), so the Role of a user opened as a dead grey box with its four options inside, while the topic view was still sending fkeys back for exactly that reason. The rule is now one pure module, form_field_readonly.js, and a section of the gobj-ui README: a form opened to LOOK still has no editable field, fkey included.

And with the control enabled, no record could be SAVED at all (7.23.72). Since 7.23.64 -- the guard that makes the form busy while it reads the picked files -- ac_form_save_record() read priv.reading_files without declaring priv, so the FIRST line of every save threw ReferenceError and the dialog just stayed open. Three releases with a treedb form that could not write. priv_declared.test.js now guards the whole class: every function in src/ that reads a bare priv. must declare it, take it as an argument, or sit inside one that did -- a missing declaration throws only when its LINE runs, and that line is usually the first of a path nobody walks every day.

Three layouts made for a treedb (gobj-ui 7.23.70)

kernel/js/gobj-ui -> 7.23.70. treedb-tree (a tidy tree read left to right, the new default), treedb-outline (one node per row, indented by depth) and radial (a sector per subtree, each ring as far out as its cards need), all on the same deterministic spanning tree of what is on screen: the first parent that reaches a node keeps it, children by hook then record order. O(n), no crossing heuristic: opening a hook moves nothing that is not under or beside it. The study of G6’s own layouts against a treedb is in the gobj-ui README.

The treedb graph’s legend is its layer control (gobj-ui 7.23.69)

kernel/js/gobj-ui -> 7.23.69. The legend strip is always there, one chip per topic: the body shows or hides the topic, ☆ makes it the main topic, +N shows its loose records, ⌖ highlights it. A main topic governs the tree -- deduced when none is chosen, its parentless records are the roots, and a parentless record of a topic the schema hangs from it is loose and drawn only on request. The three settings are preferences per treedb (hidden_topics, main_topic, loose_topics). A click on the legend reveals the whole topic; the per-topic route one page (7.23.67).

The treedb graph opens FOLDED, like a JSON viewer (gobj-ui 7.23.66)

kernel/js/gobj-ui -> 7.23.66 and yunos/js -> gui_treedb on ^7.23.66. A treedb drawn whole was a pile: a nave with a hundred and forty devices was a row of a hundred and forty cards, and a 6400-record treedb built a DOM card for every record before the first pixel. The graph now reads the treedb as a tree by its hooks: every record is fetched, only the visible ones become G6 nodes -- the roots, expand_depth levels under them (default 2), one page (fold_page_size, default 24) of children per hook, a +N chip after each page, and a pill per hook on the card (▸ devices 142) that opens or folds it. dagre reads left to right, with the ports turned to the sides. The arithmetic is treedb_fold_model.js, pure and tested. New consumer i18n key show more. The JS API docs are repinned to the new tag.

v7.18.2 (2026-09-05)

A second arrival under the same name appends nothing

store_file_bytes() wrote the manifest’s original_name into the asset node whether or not it was the name already stored, and every update appends an instance: a repeated import-assets over the same directory added one instance per asset that said exactly what the previous one said (12,134 rows of nothing on the yunovatios census). The name is compared with the stored one first, and only a NEW name is written. Test 17 of tests/c/tr_treedb_files.

A create of an existing id stored the file before refusing

treedb_create_node() ran treedb_store_files() before looking the id up, so a create-node refused as “Node already exists” had already written the blob and the __assets__ row, and left both for the gc. The bytes are taken only once the create is known to go ahead. Test 18.

The node of treedb_delete_node() is borrowed, not owned

The signature said owned. Every caller — C_NODE, gc-assets, the rollback of a create — hands it the index’s own reference, which the delete releases on success and leaves alone on a refusal. The comment, in the header and in delete_node(), says so now.

And with the convention written down, one path contradicted it: the “Not a pure node” refusal of delete_node() and of treedb_delete_instance() released the node it had just refused to delete — a reference neither of them owns, so the node could be freed while still indexed. A refusal now leaves the node exactly as it was, still indexed, like the immutable and snapshot guards beside it.

Documentation

Two standalone pages on the treedb, carded on the landing and installed by deploy.sh: /treedb-files, one write of a file column stepped through, and /treedb-system-topics, what __snaps__, __graphs__ and __assets__ hold and the door the system opens each of them through. docs/doc.yuneta.io/build_artifact.py turns any such page into the artifact version of itself.

v7.18.1 (2026-09-05)

A file column names its OWN asset hook, or the write is refused

treedb_store_files() expanded a bare id into the full reference treedb’s links speak and took a reference that arrived FULL as it came — and link_file_columns() links by the hook the VALUE names. So a record whose foto said __assets__^<sha>^as_devices_qr linked the file into qr, left foto empty and answered “Node created!”: the client asked for one column and the treedb wrote another. Since the write path started linking file columns itself, that is reachable through a plain update-node, with no autolink anywhere.

A full reference must now be exactly __assets__^<the id>^as_<T>_<C> of its own column, and a malformed one is refused at the door naming the column, instead of much later by filtra_fkeys() naming only the string. Test 14 of tests/c/tr_treedb_files.

A create whose file column could not be linked answered success

The record existed and was indexed, so it was returned — with the column empty and the caller told the node was created, which is the failure the previous release closed for the write path wearing a success. The create is undone now: one message, and either it happened or it failed. If the rollback itself cannot run the node is returned, because a failure that left a record behind is worse than the success it replaces — and it is only undone when this create made the KEY, since an instance created for a secondary key shares its key with the node that was already there and a delete takes the key whole. With the check above, the path is only reachable on a broken invariant of our own.

A second arrival of the same bytes with no name WIPED the name

original_name is the only writable column of an asset and a second arrival is an update of its node, so a manifest without original_name wrote "" over the name the file first arrived under — and appended an instance saying the file had arrived again, nameless. The GUI always sends a name; a C node forwarding a record with its bytes need not. A manifest that carries no name says nothing about the file and leaves the stored one alone. Test 15.

gc-assets did not take the bytes with no row, and the docs said it did

The collector walked the rows of __assets__ and never read the blob directory, so the orphan blob that the write order deliberately allows — the blob goes down BEFORE the index node, on purpose, because a node pointing at nothing repairs itself never — was garbage nobody could ever reach again. So was the .tmp of a write that never got to its rename. Both the design note and the reference page promised that gc-assets took them.

sweep_orphan_blobs() runs at the end of the gc, over the union of every treedb’s __assets__ index (.blobs is the TRANGER’s, and every open treedb loads the whole topic, so that union is every row on disk); the dry run says what it would take. A leftover of an id that still HAS a row is removed and NOT counted as an asset collected. Test 16.

The walk follows no link it finds inside the tree — it reads every entry with lstat — but the directory it STARTS at is opened, so source_dir=taller/escape with import_root/taller/escape -> /etc walked /etc as if it were the root’s own. The confinement is the whole security of this command, so the path is now resolved with realpath() and must still be inside the resolved import_root. tests/c/c_assets case 6 baits it with the symlink it already created.

And two smaller ones

A node created for a SECONDARY key inherits the primary’s links, so asking link_file_columns() to treat it as new linked every file column a second time — a “Child already in parent hook” per column and an instance for nothing. And build_blob_rel() in C_ASSETS checked the LENGTH of an id where treedb_blob_path() checks its alphabet: one of the two builds a served path, and they must not disagree about what an asset id is.

treedb_update_node() skips every fkey in its field loop — right for a link a person edits by linking — and treedb_create_node() links nothing at all, so the link of a file column existed only where an autolink followed. A create-node, or an update-node without autolink, stored the bytes and the index node, answered success and left the column as it was: an orphan asset that gc-assets would take, and a device with no photo. The GUI always sends autolink, so it never saw it; ycommand and the EV_TREEDB_UPDATE_NODE event do not.

link_file_columns() runs inside both writes now: it links what the kw’s file columns name, unlinks what they stop naming (""), and leaves a column the kw does not carry alone. A create with a file column costs one more instance, as the autolink a caller used to have to remember did. Test 11 of tests/c/tr_treedb_files covers create, move, clear and a reopen. DESIGN-treedb-files.md §16.8 lists what the same review left open.

The snapshot guard of an asset cannot be bypassed from the wire

treedb_gc_files() walks the snapshots once for the whole run and told treedb_delete_node() so with an internal key, __snaps_walked__, in the delete’s options — and delete-node forwards its options from the wire as they are, so a client with delete could spell the bypass and take an asset a snapshot still needs. The bypass is a parameter of a private delete_node() now; the public entry always walks. The old key is inert.

A file column that is not fkey is refused everywhere, not only at open

derive_file_hooks() refused it at open, fatally, but a run-time create-topic got nothing but a log line: treedb_create_topic() ignored the return, and the write path keyed on the word file alone, so the topic stored its bytes into a column nothing links and gc-assets took them. The check is one function now, check_file_column(), asked by treedb_create_topic() BEFORE the topic exists (refused, cause in last_message), by the open — which now logs with on_critical_error when a topic of the schema could not be created, instead of leaving it for the first write to meet as “Topic name not found in treedbs” — and by treedb_store_files(), which refuses the write.

__assets__ is a topic of the TRANGER, so a tranger holding two treedbs shares it — but each treedb loads its own copy of every node, and a link made from a treedb lands in that copy. Read from one copy alone, “no live node links it” was true of one treedb and said nothing of the other, and the row and the bytes are the tranger’s: gc-assets from the first treedb took what the second linked, and delete-node did too. No yuno opens two treedbs on one tranger today; C_TREEDB can.

Both ask every treedb’s copy now. The delete refuses, force or not — force unlinks the children of THIS copy and the other treedb’s would dangle — and a deleted row leaves every treedb’s index, or the other treedb keeps answering a row that is not on disk. The derived hooks are seeded into, and taken from, every treedb’s copies rather than the deriving treedb’s only: the desc is shared and get_node_down_refs() expects each of its fields in the node, so the first delete of a copy that lacked the other treedb’s hook failed with “field not found in the node”. Test 12 of tr_treedb_files opens a second treedb on the test’s tranger and walks the whole of it.

gc-assets holds exactly what an activation would load

It held every tagged instance as “a snapshot needs this” — but treedb_save_node() inherits the tag, so after a snap every later instance of a node carries it too, and an activation loads only the NEWEST instance per key under the snap’s tag. So the gc kept for ever whatever a node ever named after its first snap, and nothing could release it. It holds, for every snap that still exists, the newest instance per key under its tag, and only that: a node that moves on releases, and deleting the __snaps__ row of a snap (delete-node; there is no delete-snap) frees what only it held. A treedb with no snapshot does not walk at all. Tests 6 and 13 of tr_treedb_files.

The gbuffer door works through EV_TREEDB_UPDATE_NODE too

The event handler handed the write path kw["record"] while the kw’s one binary field rode at its top level, so a manifest of slices through the event met “carries no bytes”, and uploaded_by stayed empty. It hands both over now — without the extra reference the command path takes, because nothing copies an event’s kw (take_files_gbuffer(kw_is_a_copy)).

The seed guard of mt_update_node() ran only with autolink; since the write path links a file column itself, a plain update moves the ones it carries, so those are asked the same question, and only those.

Three small ones of the file columns

files_content_types handed as TEXT through open-treedb (what a CLI does) was read as an empty list and the default stood in, in silence — parsed now, or refused with a log. import-assets refused any source_dir with .. as a substring (v1..v2 included) — a path segment equal to .. now. A second arrival of the same bytes no longer rewrites uploaded_by: that is who put the BYTES there, and the bytes did not change; original_name still moves, which is what the history of names is made of. And the files_max_size description names the websocket’s ceiling: a frame has none of its own, its gbuffer is capped by MEM_MAX_BLOCK.

gobj-ui 7.23.64: the form is busy while it reads the picked files

A second Save during the read sent a second write, and a Cancel threw the save away without a word. The toolbar is disabled and the save button spins until the read lands; a second Save is refused and says so; a read whose form was closed meanwhile is dropped with a warning. Both SPAs take the range (gui_agent 0.22.53, gui_treedb 0.17.25).

gobj-ui 7.23.63: saving a record unlinked its read-only file column

The topic view sends back only the writable cols, the fkeys and the pkey, and the write goes out with autolink, which rebuilds the links from what the record carries. A file column IS an fkey but answers type: "file" since gobj-js 7.16.5, so a file column without writable — the one only a load fills, which the open declares legal — fell out of the record, and every save of any other field of its record cut its link, in silence. The exemption asks is_file now. Both SPAs take the range (gui_agent 0.22.52, gui_treedb 0.17.24).

v7.18.0 (2026-09-05)

open-treedb could not set the attributes a file column needs

import_root, files_max_size and files_content_types are SDF_RD on C_NODE, so they can only be set at creation — and cmd_open_treedb() builds the C_NODE’s kw itself and forwarded only initial_load. Since open-treedb is how every real yuno opens a treedb, they were attributes nobody could set: import-assets answered “import of files is disabled, ‘import_root’ is empty” on a node whose config named a root.

Forwarded by NAME and not by sweeping the kw, because the kw of a command carries the caller’s own keys too (__username__, the routing metadata) and none of them is an attribute of a treedb.

Found by migrating a real host (yunovatios) to file columns, which is what a migration is for.

A file column, and __assets__ as a treedb system topic

The storage half of C_ASSETS moves into tr_treedb, where it belonged: you mark a column ['fkey','file'], and treedb gives you a pseudo-filesystem. The index lives in memory as the system topic __assets__ (created at open next to __snaps__ and __graphs__, shown in system mode only), the content on disk under <treedb dir>/.blobs/ab/cd/<sha256>.<ext>, and the column holds an fkey into __assets__ — so an asset is linked, graphed, scope-checked and cascade-deleted like any other node. Design and its review: kernel/c/timeranger2/DESIGN-treedb-files.md.

icon becomes a treedb field type, and the C side is the one that ENFORCES it

A col flagged icon holds the NAME of an icon of the app’s set (yi-bolt), not a file. The nearest thing the vocabulary carried was image, which is a different case: a frontend built an <img src="yi-bolt"> and drew the browser’s broken-image glyph.

The word is added in three places, and the third is the one that matters:

That third one is the lesson. The vocabulary in tr_treedb.h reads like the source of truth and is a comment; the enforced copy is a JSON literal in treedb_system_schema.c, reached through _treedb_create_topic_cols_desc(). A schema using a flag that is only in the comment is REJECTED at treedb_open_db — “Wrong enum type” — and the yuno exits at mt_play with “Parse schema fails”, which is a yuno that will not start rather than a column that does not draw. Verified the hard way on a live node.

The system schema therefore moves too: schema_version 15 → 16 and the cols topic 9 → 10, so the persisted __system__ of every store learns the new flag on the next start instead of offering the old list to the schema editor.

An asset gets one field a person may edit

assets.original_name becomes writable in the canonical topic that c_assets.h publishes for hosts to copy. It is the only column that can be: every other one describes the BYTES — id is their sha256, content_type, size and t measure them, source_path and uploaded_by are facts of the load — and editing one would lie about the content. A name is a LABEL, which is a different kind of thing.

Without it the topic has no writable column at all, so its record form opened holding nothing but the read-only id.

The JS layer

Carries its own versions and reaches the apps through npm.

gobj-js 7.16.4 → 7.16.5 and gobj-ui 7.23.60 → 7.23.62: the file column control

A col flagged ['fkey','file'] can be filled by a person now. Until this it was written by a C caller or by import-assets and by nobody else.

The last two were found by driving a real form against a real census, not by a fixture: the control drew correctly and the log said it did not.

gobj-js 7.16.3 and gobj-ui 7.23.50 → 7.23.59

The treedb topic table, one round, in the order the defects were found:

gobj-ui 7.23.49: a search that could not see inside an fkey

A treedb row is not flat. With list_dict an fkey arrives as a LIST OF OBJECTS — [{id, topic_name, hook_name}] — and the topic table’s search box stringified every value with String(val), which for that is "[object Object]". So searching a topic of devices for the place they sit in — the value an operator actually has in mind — never matched anything, while the cell plainly rendered that id: what you see and what is searched were not the same thing.

yui_row_search.js walks into lists and objects and reads only the id of an fkey: topic_name and hook_name are the same two words on every row, so matching them would turn any such term into a wildcard over the whole topic.

Two more in the same table. The page-size selector offers All on a remotely paged topic — filterMode: "local" means the filters only see the page that is loaded, and nodes has taken limit: 0 for “every one” since paging landed, answering the plain list. And each header filter carries a ✕: a column filter was undone by deleting what you typed, and with several of them set, getting back to the whole table was an exercise in remembering which ones you had touched.

gobj-ui test-app: the maplibre worker asset carries its version

The worker and its shared chunk were the only assets of the bundle emitted under a FIXED name, so a static host serving /assets/ with a long max-age hands a returning browser the OLD worker against the new bundle — and worker and main thread speak a private protocol that changes between maplibre versions. Paid for in yunovatios, where a cached 6.4.1 worker made the 6.7.0 GlyphManager answer t.codePointAt is not a function once per tile. The emitted names now carry the installed version.

yunos-js 0.22.46 / 0.17.18

The agent console stops its JSON viewer before destroying it (gui_agent 0.22.45), and both SPAs then raise their ranges to the libraries above — @yuneta/gobj-ui ^7.23.49 and @yuneta/gobj-js ^7.16.2 — which is what carries the fkey search fix into gui_treedb’s table. Ranges only: no code of either app changes. Detail in that repo’s own CHANGELOG.

7.17.3

An absent DTP_JSON is json_null(), and four predicates disagreed about it

set_default() materialises a DTP_JSON with an empty default as json_null() — a valid pointer — so if(!jn) over one of them is a branch that can never be taken. It reads exactly like the guard everybody writes, it compiles, and it survives every test that exercises the present-value path. It reaches further than attributes: a subscription is built with gobj_sdata_create() too, and its optional __config__, __global__, __local__ and __filter__ are declared that way.

The tree already had the answer written four times, and the four disagreed:

C NULLjson_null{} []""scalar
empty_json() — helpers.h, public inlineFALSETRUETRUEFALSEFALSE
json_size()==0 — helpers.c, publicTRUETRUETRUETRUETRUE
json_empty() — tr_treedb.c, privateTRUETRUETRUETRUETRUE
is_unset_value() — c_treedb.c, privateTRUETRUETRUETRUEper type

The one in the public header was the wrong one, and wrong on precisely the case being chased: jansson’s json_is_array()/json_is_object()/ json_is_null() are each NULL-safe on their own, so all three fell through and empty_json(NULL) answered “not empty”.

Consolidated, not extended. json_size() moves to static inline in helpers.h (and takes const json_t *); empty_json() is now json_size(jn)==0, so there is one switch and the two cannot drift again; the private json_empty() of tr_treedb.c is gone. Alongside it, json_absent(jn) — true for C NULL and json_null and nothing else — for the seven places that already wrote (!x || json_is_null(x)) by hand.

The rule, in CLAUDE.md and GOBJ.md §8.15: never if(!jn) over a json. empty_json() almost always — an empty filter is as much “no filter” as an absent one, which a null test does not cover — json_absent() where null and empty differ, and the shape itself when the shape is the contract.

The guards that were dead, and the double free one of them was hiding

🔴 ycli shortkeys. mt_create read the shortkeys attr and created the dict if(!priv->jn_shortkeys) — never — so on a config that had never saved one the dict stayed a json null and add-shortkey wrote into it silently. Fixing only that guard would have broken ycli at exit: mt_destroy carried a JSON_DECREF of a reference gobj_read_json_attr() hands out BORROWED, harmless only while the value was the json_null() singleton (refcount (size_t)-1). With the dict actually created it is a double free. Both go in the same change; the three cmd_*_shortkey guards now ask for the shape.

c_tcp_s / c_udp_s cert reload. crypto is DTP_JSON with a null default, so reload-certs’s “‘crypto’ attribute is empty” could not be reached and the reload went on to hand a json null to ytls.

C_IEVENT_CLI resent subscriptions without their filter. Two bugs in the same four lines. The three optional keys were read with KW_REQUIRED, which logged an error each per subscription — the flag was simply wrong on keys that are optional by contract. And __filter__ is any json — the callers pass a list of alternatives — while it was read with kw_get_dict(), the dict-only reader, which answers NULL for a list: on reconnect the subscription was resent without its filter and the remote published everything.

crypto is a dict, so it says DTP_DICT now

The guard was necessary because the type lied. DTP_JSON means any json, null included, and json2item() proves the difference: it refuses a non-object for a DTP_DICT with a log, while for a DTP_JSON it accepts whatever arrives. crypto was declared DTP_JSON in seven gclasses (c_tcp, c_tcp_s, c_udp_s, c_prot_http_cl, c_auth_bff, c_task_authenticate, c_smtp_session) and is a dict in all seven — and they hand it down to one another, so a non-dict accepted at the top reached ytls at the bottom. It fails at the first hop now.

Safe on a node with a persisted value, checked rather than assumed: neither c_tcp_s nor c_udp_s ever calls gobj_load/save_persistent_attrs (and they are children, not services, so they could not), nothing in the tree writes the attr with gobj_write_json_attr(), and there is no "crypto": null in any config or store. A stored one would fall back to {} with a log — which is what it effectively was.

With the type honest, the two json_object_set_new(jn_crypto, "trace_tls", …) at listen time can no longer fail — and they say so if they ever do, along with the ssl_server_name one in c_tcp. All three used to return -1 into nothing.

gobj-js 7.16.2: the same question, spelled the same

empty_json() lands in gobj-js/src/helpers.js with an identical truth table (verified side by side, both runtimes, eight values). Javascript has no second null, so if(!x) happens to work there — and that is the divergence a port crosses in both directions. json_absent()'s twin is the is_null() that was already there.

7.17.2

C_ASSETS: an audit for silent errors, and gc-assets could empty the store

Asked to sweep my own code for failure paths that write nothing to the log. Eight, and two of them mattered.

🔴 gc-assets deleted everything it could not judge. node_is_linked() answered FALSE for a node it had no hooks to look at — and asset_hook_names() answers an empty list whenever the topic descriptor cannot be read. So a treedb that would not answer meant every asset looked like an orphan, and the garbage collector removed the whole store, rows and blobs, reporting success. The comment right above that function already said “not being able to prove an asset is an orphan is a reason to keep it” — the empty-list case walked straight past it. Cannot tell now means linked, and gc-assets refuses outright when it cannot read the hooks, because a collector that cannot tell must not guess.

An asset node with no id was skipped silently by both gc-assets and the census index — one leaves an unreachable blob behind, the other makes every image that node holds unreachable. Both say so now.

Three refusals of store_asset — empty, over max_size, content_type not allowed — put the reason in the answer and nothing in the log. The answer reaches whoever called; the log is what somebody reads when twelve thousand images went by and a few did not arrive.

And yui_asset_element()'s onerror drew the marker and told nobody. It reports through log_error now: a failure only a user can see is a failure nobody measures.

C_ASSETS: source_path is a LIST, and 147 photographs say why

An asset is its content, so N files with identical bytes are ONE asset — and each of those files came from its own path, which is what a loader links by. source_path held one, so it held the last one written and lost the rest.

It is not a corner case. In the yunovatios census 148 measurement points share one byte-identical photograph: content-addressed that is a single asset, so 147 of them named a path no asset carried, were never even looked up, and came out with no image. The load reported success, and it was found counting rows afterwards.

source_path is an array now and store_asset accumulates every path that ever led to that content. A store written before this still reads: a bare string is taken as a list of one.

And a re-upload of a path already known now writes nothing at all — before, every re-run of an idempotent load appended a row per asset to an append-only store. Proven on the real case: the shared asset came back carrying 115 paths with 115 devices on its hook, where it used to carry one of each.

The reason this release exists at all

Both fixes above were in main and reachable by nobody. A node that carries a runtime-only SDK — outputs/, outputs_ext/, tools/, no kernel/ and no .git — cannot rebuild the framework, so a fix reaches it as a package or it does not reach it. The census on the central node stored its twelve thousand assets and linked none of them, because its store_asset was the one that writes source_path as a string.

Three releases in a row have been cut for this reason. It is worth saying plainly: for those nodes a commit is not a fix; a tag is.

kernel/js/gobj-ui moves to 7.23.48, which carries yui_asset.js — the browser half of C_ASSETS: it resolves a treedb reference to an asset id through all three shapes a schema can hand it (bare id, topic^id^hook, and a list of either), asks the service for the mode it prefers, and draws a marker when an image does not arrive and logs it.

7.17.1

C_ASSETS takes video and audio too

A node of a treedb owns more than photographs. allowed_content_types now carries video (mp4, webm, quicktime, ogg, x-matroska) and audio (mpeg, mp4, ogg, wav, webm, flac) alongside the images and the pdf, and both mime tables know them.

The pairs that share a container are split by extension, deliberately: the extension is the only thing a web server reads to pick a Content-Type, so .webm is video and .weba audio, .mp4 video and .m4a audio, .ogv video and .ogg audio. Getting that wrong does not fail — it serves a sound file labelled as a film.

max_size moves from 32M to 128M, and it is a memory limit as much as a policy one. There is no streaming path: an asset is hashed and written whole. put-asset costs the worst, because the base64 arrives inside the kw and is then decoded — one call peaks at roughly 2.3x the file — while import-assets only pays the file itself. Raising it further for big media means checking the yuno’s own MEM_MAX_BLOCK first: a single base64 string above that is refused by the allocator, not by this gclass. Said out loud in the attribute description, in c_assets.h and on the doc page, because a limit that silently governs RAM is the kind that is found the hard way.

image/svg+xml stays out of the default, for the reason it always was: an svg served from the app’s own origin runs script.

C_ASSETS: put-assets, because a batch line carries ONE file

import-assets reads a directory that is already on the node, which is right for a migration and wrong for a norm: the initial data of a yuno belongs in yunos/batches/, on the authoring side, and the deploy sends it from there. A batch line carries ONE file (content64=$$(...)), so a census of twelve thousand images is either twelve thousand commands or a few dozen bundles.

put-assets takes a bundle: a JSON array of {original_name, source_path, content_type, content64} — the same form the rest of the batches use, so it needs no parser of its own and a person can read it. The bundle is TRANSPORT, not storage: the images stay files where they are authored, and the deploy packs them the same way $$() base64s them. Nobody commits base64.

One bad entry does not stop the bundle. A load of that size that aborted halfway would be neither retryable nor comparable, so every failure is logged with its name and the answer reports stored and failed.

C_ASSETS: two defects a live run found and no unit test could

Both were found driving the gclass through a real agent, and neither fails loudly — which is the point.

get-asset id= answered “Yuno not found”. command-yuno hands its WHOLE kw to gobj_list_nodes() as the filter that picks the yuno, so a parameter named like a field of the yuno record becomes a filter on that field: an id of a sha256 matches no yuno. The error names the YUNO and never the parameter, so it reads as the service being missing. The parameter is asset_id now, with the bare id kept as a fallback for a caller that never crosses the agent. CLAUDE.md warns about exactly this and calls id “the best-known case”; the warning was there and the parameter was named id anyway.

orphan=1 listed everything. command-yuno does not coerce, so a boolean arrives as the STRING it was typed as, and kw_get_bool() answers the default for a string unless it is given KW_WILD_NUMBER. orphan was read without it, so the filter did nothing and list-assets orphan=1 returned the whole store — which reads as “nothing is an orphan”, the opposite of the truth, and would have sent somebody hunting for a bug in the hooks. orphan, force and both dry_runs take KW_WILD_NUMBER now, and the test drives them as strings the way the agent does.

7.17.0

C_ASSETS: the bytes a treedb node owns but cannot hold

A treedb node often owns something that is not json — a photo, a plan, a signed pdf — and today those bytes live wherever whoever loaded them put them. In yunovatios that is 356 MB of census images under /yuneta/store/resources/censo_memorias/, referenced from devices.foto as a free string, pushed to the node by an rsync from a developer’s laptop. Nothing owns them: no command writes one, so a user cannot add or replace a photo from the SPA or from ycommand; nothing checks the path resolves, so a renamed topic breaks every image in silence; and the web server serves the whole prefix to anyone who reaches the vhost, which walks straight past the scope the treedb enforces on the nodes themselves.

They cannot go IN the treedb: it is held in memory and timeranger2 rewrites the whole record on every update, so a 40 KB photo would be rewritten every time its node changed state, and would ride along in every page of nodes. (The treedb blob column type is not binary — it is free-form json.)

C_ASSETS does what the agent already does with yuno binaries: the bytes in a directory the service owns, one node per asset in the treedb, and commands as the only way in. The asset id is the sha256 of the content, so the same bytes stored twice are one asset, a whole census reload creates nothing, a served url can be cached for ever, and replacing an asset is a new id plus a relinked node — which is what makes the node’s own history say which photo it carried, and when.

The consumer’s column stops being a path and becomes an fkey to the asset topic, so an asset is linked, listed, graphed, scope-checked and cascade-deleted like any other node, and an asset no node links any more is visibly garbage (list-assets orphan=1, gc-assets).

Commands: put-asset, get-asset, list-assets, delete-asset, import-assets, gc-assets. Writes are refused on a replica and gated by the write / read authz of the service.

get-asset answers in one of two shapes, and the SERVICE decides which: a signed url when public_url and sign_secret are configured, the bytes inline when they are not. So the caller has one code path, and a node with no web server in front of it still shows its images instead of showing nothing. The signed form reproduces, byte for byte, what nginx’s secure_link_md5 "$secure_link_expires$uri <secret>" hashes — verified against openssl on four cases. The client address is deliberately not in the signature: it would tie the url to one ip and break every phone that changes network mid-session; the 15 minute default lifetime is what limits a leaked url.

import-assets is the bulk path, and it moves no bytes at all. One command walks a directory that is already on the node and turns it into N assets — for the yunovatios census, 12 281 files that are already sitting in the store. Sending hundreds of megabytes through the control plane, one base64 message per file, is the thing it exists to avoid. It reads an arbitrary path, so it is confined to a configured import_root and refused outright when there is none.

Three separate things do the confining, and a security review of the pushed commit was right to look even though it holds: only ONE of them is visible at the call site. The explicit .. guard refuses rather than silently resolving somewhere else; build_path() strips the leading / of every segment after the first and clamps .. against it, so an ABSOLUTE source_dir lands INSIDE the root (/etc → <import_root>/etc), not at /etc; and walk_dir_tree() lstat()s, so a symlink is neither a regular file nor a directory and cannot lead the walk out. The test drives all three with hostile input rather than asserting it from reading the code, and the comment at the call site names the other two so neither gets “simplified” away on the grounds that the other covers it.

The topic belongs to the HOST, not to this gclass: an asset’s fkeys point at the host’s own topics, so only the host can write those hooks. C_ASSETS never creates it and refuses to work when the topic it was pointed at cannot hold what it is about to write — a blob on disk whose row failed to be written is a file nothing can ever find again. The canonical topic and the nginx block are in c_assets.h.

image/svg+xml is not in the default allowed_content_types on purpose: an svg served from the app’s own origin runs script.

tests/c/c_assets covers the lot, and two of its checks exist because the bug was written first and both were silent. gobj_topic_desc() answers {topic_name, pkey, ..., cols} while topic_desc_hook_names() walks a LIST OF COLS — handing it the dict does not fail, json_array_foreach() over an object iterates nothing, so it answers “no hooks” and every asset looks like an orphan. And with the hook_size option a hook is not a number: it renders as [{"size": N}], so an EMPTY hook is a NON-EMPTY list and “the list has elements” is the wrong test — it made every asset look linked and gc-assets delete nothing. node_is_linked() now reads the shape it asked for, and counts anything else as linked: that answer decides what a garbage collector removes, and not being able to prove an asset is an orphan is a reason to keep it.

public_url and sign_secret are SDF_WR and repeated in mt_writing. They were SDF_RD with a cached const char *, which is a pointer INTO the attribute — writing one at runtime left the copy dangling. It also means a node can be switched between the two serving modes without a restart.

--with-http_secure_link_module added to both configure-libs.sh configure blocks. It is what lets a web server check C_ASSETS’s signed urls by itself, with no round trip to the yuno. Core module, no new dependency, no path/name/symbol change — but it does mean the nginx that ships in the .deb/.rpm has to be the rebuilt one before a node can serve assets that way. Until then those nodes answer inline, which is the fallback get-asset was designed around.

C_AUTHZ says WHICH authz db it did not find

“No authz db, authz only to local access” carried the path it looked for as "path", "%d", path — a const char * formatted as an int, so the one field that names the missing directory printed the pointer ("path": 676502376). It is the field the message exists for: a follower (master=false) that loses the start-up race against the master that creates the store, and a deployment that genuinely has no authz db, produce the identical line. The format is %s now, and the line also carries master, which is what tells the two apart.

Found on a from-scratch yunovatios install: db_tracks_ce and db_tracks_co checked 24 ms and 22 ms before their store existed, came up with authz disabled for the life of the process, and answered list-users with nothing while the master listed three accounts.

The immutable mark of 7.16.4 protected the record: delete-node refused a seed, force included. Its links were not covered — the mark is one md2 bit, and tr_treedb does not know which links matter — so unlink-nodes, an update-node with autolink that did not repeat the fkeys (what kw omits, treedb_clean_node() drops), a force delete of the parent, or a link-nodes into a single-valued fkey could still leave the seed hanging off nothing until the next start re-linked it. A scope in yunovatios is exactly such a link. C_NODE, the owner of initial_load, now refuses those four writes when they would cut a link a seed is declared with (“initial_load: cannot unlink a seed link”, “... update would drop a seed link”, “... cannot delete the parent of a seed link”, “... link would overwrite a seed link”); force overrides none. No column flag was added: a flag would freeze the column for every record of the topic, and the declaration already says which links matter. The links a person adds to a seed afterwards stay ordinary.

The fourth one is the least obvious, and it is why the guard cannot be read off the other three: a link does not always add. _link_nodes() branches on the shape of the child’s fkey column — a list takes the new ref beside the ones already there, an object keys it, but a string column has room for one and is written over without a comparison. So link-nodes to another parent through a single-valued fkey cuts the declared link as surely as an unlink does, and a test whose fkeys are all lists cannot see it.

apply_initial_load() now runs in two passes, the way treedb_open_db() loads a store: every record first (created without its fkey values), every link second. A child declared before its parent used to be created with its link failing (“parent node not found”) and healed on the second start; now the seed comes up whole on the first, whatever the topic order. New test tests/c/c_node_initial_load, which carries both fkey shapes on purpose: a list (users.departments) and a string (machines.department). YUNO_TREEDB.md §3.10 carries the contract.

7.16.5

The memory audit follows its own switch

CONFIG_DEBUG_TRACK_MEMORY is the Kconfig knob that “enables track memory to find leaks”, and every guard in gbmem.c also demanded CONFIG_BUILD_TYPE_DEBUG. A RelWithDebInfo build with tracking enabled therefore tracked nothing: get_cur_system_memory() answered 0 for the life of the process, "cur_system_memory": 0 on every log line, and the shutdown audit (print_track_mem()) was not even compiled in -- which also made the get_cur_system_memory()==0 checks of every ctest pass on any leak. The second half of the guard is gone; tracking now follows the one switch the menu shows. Proven with a probe that leaks a block on purpose: silent before, “system memory not free” + the block after.

7.16.4

initial_load: a treedb declares what it cannot come up without

The records a system cannot start without -- the seed role, the admin account, the root of the tree a scope hangs from -- were written by a batch that somebody had to remember to run. A batch writes them once. The day one is deleted the system is up, answering, and showing nothing, and the deletion raises no error because a missing record is not an error.

C_NODE gains an initial_load attr: one entry per topic, a list of records, with the links riding inside each record as fkey values (parent_topic^parent_id^hook, the same form the child stores). It is applied in mt_start right after the treedb opens, master only, on every start:

The re-link is the half that is easy to miss. Deletion is not the only way to blind a seed: a scope is a link, and treedb_unlink_nodes() carries no immutable guard, so an untouchable record can still be left hanging off nothing. Re-linking on start repairs that; immutability is the defence between restarts. And it re-links without ever re-writing, because an autolink over an existing node runs treedb_clean_node() first, which would drop every link the seed does not declare -- the ones a person added on purpose.

Reachable two ways: the new initial_load parameter of C_TREEDBS’ open-treedb, or the attr set directly by a gclass that builds its own C_NODE.

C_AUTHZ is now one caller of it. The same loop lived in its mt_start, written for one treedb; it hands Authz.initial_load down to its C_NODE child instead, and the loop is gone from c_authz.c. The attr keeps its name, its shape and its behaviour -- command_delete_user, which seeds an immutable user through it and checks that the delete is refused, passes unchanged. The explicit time stamp the old loop injected went with it: a col flagged time with no value supplied already gets the current time from treedb itself, so it was always redundant.

7.16.3

Cut so that the webstats change reaches the yunovatios nodes: they carry a sparse SDK, so an SDK yuno cannot be built there and only arrives in the .deb / .rpm.

C moves in four places -- the flat json (json2flat / flat2json), c_tranger’s delete-key, diff-schema answering differences nobody had made, and webstats saying when a log stopped rotating -- plus the three bench tests that were double frees in the tests themselves. The rest is the JS layer (gobj-js 7.13.9 -> 7.16.1, gobj-ui 7.23.16 -> 7.23.45, yunos-js 0.15.1 -> 0.22.44), whose detail lives in each repo’s own CHANGELOG.

webstats says when a log stopped rotating

A rotation is two steps -- logrotate renames the file, the web server reopens -- and when the second one is lost the server keeps writing down the old descriptor: <path>.1 grows while <path> stays as logrotate created it. Nothing reported it. The server serves, logrotate exits 0, and the daily report was still built, because this yuno reads both files.

And it does not heal. Once <path> is empty, notifempty skips it, so the rotation is never attempted again: one lost signal costs the rest of the life of the node. It cost eight days on a real node, and it was found by somebody listing the directory.

The signature needs no history: after a healthy rotation the live file is the one being written, so its mtime runs ahead of the .1; the other way round means the reopen was lost. New attr rotation_stall_minutes (default 120, 0 disables) is the margin that keeps a site with no traffic since the rotation from reading as broken -- there neither file moves, and neither is meaningfully newer.

Reported in three places, because they have different readers:

It does not fix it: signalling a web server is not this yuno’s business, and a report generator that restarts services is a different and worse thing.

The three broken tests of the bench, and why they broke on one particular day

test_tr_treedb, yev_events/test_yevent_traffic5 and traffic6 were aborting with heap corruption -- “corrupted double-linked list”, “free(): chunks in smallbin corrupted”. All three were reference accounting in the tests themselves, not in the library.

And they have a date of origin: on 2025-07-13 test_json() started FREEING its argument (e6b480f83). From that day, every place that handed it a borrowed pointer was broken. The offending calls are from 2024-11-02 and 2024-11-08: they were correct when they were written.

A real defect of the SDK, found along the way: json_check_refcounts() tested if(!jn), logged it and carried on to json_typeof(jn): it blew up on the very NULL it had just reported. A checker that dies of what it exists to detect. It returns now.

How it was found, which is what matters next time. ASan saw nothing: the test tree links the installed libraries from outputs/lib, not the ones in the build tree, so instrumenting the build does not instrument what runs. It took building the SDK and jansson with -fsanitize=address -- jansson with its own generated config headers, or it produces invalid json -- and relinking the test by hand against those libraries. With that, ASan pointed at the three exact lines in a minute.

Two fixes of the JS runtime that reach EVERY SPA

Both came out of using the consoles against real nodes, and both were in the library, not in the applications.

gobj-js 7.16.1 -- an event addressed to a service that no longer exists is dropped, not broadcast. C_IEVENT_CLI looked for the destination service and, on not finding it, fell through to the default delivery: publish it to every subscriber of the transport. That path is the right one for a message that names no destination and the wrong one for a message that does -- an addressed message belongs to its addressee or to nobody.

What triggers it is the normal end of a view’s life: it is mounted under a service name, it subscribes to a backend event, the user navigates elsewhere, the view is destroyed, and the frames already on the wire keep arriving for a name nobody answers to. They ended up on the application gobj’s null event subscription -- which is null on purpose: naming EV_ON_OPEN in a subscription forwards it upstream and the remote rejects it -- and its FSM does not declare an equipment frame, so it said so once per frame: 38 errors in a 26 ms burst on a node with 38 devices. Now it is dropped with a warning naming which service and which event were lost. The path with no destination is not touched, and tests/ievent_dispatch.test.js pins the three cases together, because the fix is only correct if the third one still works.

⚠️ The C side carries the same open TODO (c_ievent_cli.c) and was left as it was: it is consolidated kernel, and how a backend routes is not a decision of the JS side. Until it moves, a C client and a JS client do different things with an orphaned addressed event.

gobj-ui 7.23.45 -- the wheel over a graph popover, and a card that can be read and closed. No graph popover could be scrolled with the wheel: you had to drag the bar. G6’s zoom behavior binds a wheel to the container and calls preventDefault() on every notch whatever the target is, and then declines to zoom because the target is not the canvas -- so the gesture was cancelled and used by nobody. It is stopped at the popover, which fixes all five. And a node’s detail card came at caption size with a 13x16 px ✕: the sizes moved out of the inline styles into the CSS -- a media query cannot reach an inline style -- and the header is sticky, because with it scrolling away the ✕ disappeared exactly on a phone.

The JS versions say again which SDK they are built against

gobj-js 7.13.9 -> 7.16.1, published. From now on a JS package of the SDK does not run ahead of the C one except in the third index: the first two are those of YUNETA_VERSION and the third is the package’s own life between releases. 7.14 and 7.15 are skipped on purpose -- the number does not count releases of the package, it says which SDK it belongs to.

gobj-ui breaks the rule and it cannot be fixed by renumbering: it is at 7.23.45 with the C at 7.16.2, and publishing a 7.16.x behind it would be a version lower than the published one, so npm would keep 7.23.45 as latest. The rule is forwards; it is written down in CLAUDE.md.

The flat json: json2flat / flat2json, in C and in JS

A json seen as a table: one row per LEAF, the id is the path of the item and the value its value. To store, to compare and to diff it is a far better form than the native one -- and it is the only one a person can read when two configurations disagree.

{"a": {"b": 1}, "c": [10, 20]}   ->   {"a`b": 1, "c`[0]": 10, "c`[1]": 20}

Half the piece already existed and it was broken. json_flatten_dict() / json_unflatten_dict() had been in kwid.c for a long time, with a warning in the header: “digit-only keys are reserved for array indexes”. Measured before touching anything:

inputwhat it returned
{"1630": {...}} -- a yuno idan array of 1631 elements
{"a": {}} / {"a": []}the key disappeared
{"a`b": 1}it was split into {"a": {"b": 1}}
{"a": {"": 1}}it came back {"a": 1}, one level short
"a`1000"1001 elements materialised for a single value

The rule the format imposed was broken by our own data: a dictionary indexed by yuno id is as ordinary as it gets here.

The new grammar, the same in both languages:

flat2json() refuses instead of guessing: an id that is a leaf and a container at once (the result would depend on the order the keys are read in), an index above the cap, a path deeper than the cap -- the old code truncated to 256 segments in silence.

Index and key are two TYPES, not two spellings: flat_key_split() returns the index as an integer and the key as a string. Its own test exposed it: as strings, the key "[0]" and the index 0 both came back as "[0]", which is exactly the ambiguity [N] is there to remove.

Also flat_diff() -- {added, removed, changed} -- and flat_apply(), which works on the flat form on purpose: there an id addresses one value, so applying is putting and removing, with nothing to walk and nothing to guess.

The two old names remain, delegating to the new ones and marked deprecated: there is no stored data in the old format to be compatible with -- the only production caller, flatten-subscribers of the MQTT broker, prints it on a console.

New tests: tests/c/kw/test_json_flat.c (33 checks) and kernel/js/gobj-js/tests/json_flat.test.js (36). The ids they pin are the same in both: a flat json is written by one side and read by the other.

Mostly the JS layer (yunos-js 0.15.1 -> 0.22.44, gobj-ui 7.23.45, gobj-js 7.16.1) in eight parts, plus two things the C side was missing: a tranger topic could be listed key by key and never PRUNED, and diff-schema answered dozens of differences nobody had made.

Two things learn to act on a SET: the connections table of gui_treedb, because a pasted deploy centre is two hundred rows, and the treedb GRAPH, which could be rearranged one node at a time and no other way. Its camera toolbar also stops promising what it does not do.

And the URL learns to hold a POSITION. Three separate places threw one away: a reload on a deep tab route answered with another tab’s default, an action route came back to the route a view is declared at rather than the one you were looking at, and both consoles were deciding this in their own c_app.js -- which is how one of them could be wrong while the other was right, and nothing said so. The deciding is one shared piece now.

And the JSON viewer learns to READ the same document three ways. A tree is the right shape for finding one value in a large document, and the wrong one for reading it as it is written or for seeing its shape — so C_YUI_JSON now also shows the raw text of what it holds, and a graph of it.

And last, the three G6 graphs learn to be OPERATED by a finger. They have always drawn correctly on a telephone; what could not be done on one was anything else — the zoom was two buttons because G6 gives it to the wheel, the context menu had no door at all, the controls a finger has to land on were sized for a pixel, and the treedb graph’s multi-selection hung off a key a telephone does not have. And the plainest thing of all could not be done either, which is why it was found last: a finger could not MOVE a node.

And the sixth is what a developer READS. Two rounds fell out of looking at the gobj tree: the trace of the FSM is the framework’s whole debugging story, and in the browser it was hard to read for three separate reasons. It arrived in the legacy shape (three lines per transition where a node writes one, because the JS port’s default never matched the kernel’s, and then because four of its six trace sites had no other shape to write). It lost its INDENTATION on the way to the screen, so it read as a flat column where the console read as a tree. And it could not be quietened: the filter that promises to hide recurring events could not see the machine trace at all, so a yuno with one timer buried everything else under two lines a second — in the window, and then, once the window was fixed, in the console beside it.

And the seventh is the gclass itself. view-gclass answers a complete description -- attrs, commands, methods, trace levels, FSM -- and the only thing that drew it was a JSON tree: correct, and unreadable. It is laid out by ZONES now, with its machine as a matrix or a graph, and reading a real gclass with it found two defects in the drawing and one in the framework’s own teardown.

Detail in those repos’ CHANGELOGs.

Added

Fixed

diff-schema answered 43 differences nobody had made

A treedb of 45 columns reported 43 differences, every one of them fillspace “only in stored” with the value 10. No Apply could ever settle them, because there was nothing to apply: fillspace DEFAULTS to 10 in the system schema, almost no schema writes it, and the two sides of the comparison were not symmetric about that.

The projection of a column copies the attributes the schema DECLARES and no more; the stored node went through treedb, which fills every attribute the descriptor gives a default. So an attribute the schema never mentions is absent on one side and holds its default on the other, and the comparison read that as an operator addition.

It is the same failure is_unset_value() was written for — its own comment says “those 586 defaults buried the 6 that somebody made” — except that one only knows the EMPTY value of a type, and what bit here is the DECLARED default, which is a different thing. is_declared_default() knows it.

The projection is deliberately NOT filled with defaults instead: it is what the projector UPSERTS, so a default written there would overwrite the value an operator set by hand on an attribute the schema does not declare. The asymmetry is real and belongs in the comparison, not in the projection.

Measured after the fix: 43 differences -> 0 on a treedb whose schema matches, and on a second node the panel now reports exactly ONE — a column that really is only in the store. Which is what the panel is for.

7.16.2

JS layer only (gobj-js 7.13.2 -> 7.13.5, gobj-ui 7.10.5 -> 7.14.3, yunos-js 0.13.3 -> 0.15.1). Each repo carries the detail in its own CHANGELOG.

Fixed

Added

7.16.1

Fixed

7.16.0

Added

Fixed

7.15.0

Added

Fixed

Changed

7.14.0

A schema stops being read by its rows. The order of the columns is part of the schema and no longer the order the filesystem happens to hand the projection back in; and the console that edits schemas stops showing the three topics they are STORED in and shows the schema they ARE — and stops losing your place in it.

The consoles also start saying WHICH machine you are looking at, in the two places that never said it: a treedb tab named after a treedb that lives on four backends, and a collapsed node hiding the yunos it has open.

Added

Fixed

7.13.2

A key that says where it belongs: the schema stored as data stops being addressed by a counter, and starts being addressed by its own name.

Changed

7.13.1

A schema you can read: the descriptor learns to say what NAMES a record, and the two treedb views stop showing storage where a schema was asked for.

Added

Changed

7.13.0

A schema stops being something only the C literal knows.

The __system__ treedb holds it as DATA again — projected on every open, reconciled by version, and able to rebuild the schema a treedb opens with — and gui_agent grows the Schemas workspace that edits it: the treedbs of any yuno of any node, over the one control-center session the console already has, as tables or as a G6 graph. The node’s own agent is one of the entries.

The other half is honesty about who may write. Only the MASTER of a treedb’s tranger can, and until now a replica answered success and lost the row at the next reload; it refuses now, treedb-info says which one a yuno is, and the editor opens read-only instead of turning every click into a toast.

And a family of small lies found by pulling one thread: every authzs command answered with its permission list in the COMMENT field — which the client refuses — four of them answered nothing at all, and the one helper behind them leaked a whole kw per call.

Fixed

TreeDB

Documentation

Added

Fixed

7.12.0-2

A packaging revision, not a new version: the tree under kernel/, modules/, utils/ and yunos/ is the same one 7.12.0 was cut from. The packages are rebuilt as yuneta-agent-7.12.0-2 and attached to the existing 7.12.0 tag.

Fixed

7.12.0

A minor for what was found, not for what was added.

The agent had been demoting yunos: a version comparison packed its segments into an int, 1.9.0.0-2 overflowed into a negative number, and every deactivate-snap re-appended the OLDER release as the primary — for eleven days on a client node, with nothing in any log to say so. The comparison was both the decision and the only guard, so when it lied there was nothing left to notice. It is version_cmp() in the SDK now, comparing segment by segment, tested against that node’s real version chain; and the direction is checked on its own, logged either way, and a release that does not move forward needs force=1.

Two more of the same shape: a msg2db accepted records it could never load back (4447 of them on that node, and 4447 log lines at every start), and the package would unpack another machine’s build over a node that compiles its own — foreign glibc archives into outputs/, which a static link takes in silence and the heap pays for at run time. Both refuse now, at the point where the mistake is made.

Fixed

Changed

Fixed

7.11.0-3

A packaging revision, not a new version: the tree under kernel/, modules/, utils/ and yunos/ is the same one 7.11.0 was cut from. The packages are rebuilt as yuneta-agent-7.11.0-3 and attached to the existing 7.11.0 tag.

One change, and it closes the difference the previous revision opened.

Changed

7.11.0-2

A packaging revision, not a new version: the tree under kernel/, modules/, utils/ and yunos/ is the same one 7.11.0 was cut from. The packages are rebuilt as yuneta-agent-7.11.0-2 and attached to the existing 7.11.0 tag.

Everything here comes from one install that failed on three nodes at once, and from what that failure left behind.

Fixed

7.11.0

A minor for one reason: a public call changed its name and its signature, one day after it got one.

7.10.0 added gobj_post_message() to C. The name was wrong and the shape was wrong, and it took the compiler to say so — gobj_post_event(dst, event, kw, src) already existed in gobj-js and in the ESP32 port, and the ESP32 component includes the Linux gobj.h, so the three-argument version did not even build. Two implementations already agreed; C is the one that moved. gobj-js then moved too, off a setTimeout(…, 10) and onto the same contract.

The rest is the node’s web server: it has its own systemd unit now instead of being a side job of the init script, and on Rocky it needs a label to be allowed to start at all.

Fixed

Changed

7.10.0-2

A packaging revision, not a new version: the tree under kernel/, modules/, utils/ and yunos/ is the same one 7.10.0 was cut from. The packages are rebuilt as yuneta-agent-7.10.0-2 and attached to the existing 7.10.0 tag.

Both changes are about the node’s web server, and both came out of one daily report that arrived empty.

Fixed

Changed

7.10.0

A minor, not a patch: the framework gets a call it did not have.

gobj_post_message() names something every gclass already did and had no way to say — do this, but not on this stack. Until now that was written as a C_TIMER0 of one millisecond, which is a time for something that is not a time, and which cost the name of the event: every deferred continuation arrived as EV_TIMEOUT, so the machine trace, which is the execution log of a yuno, said “timeout” instead of what happened.

The call is not new to Yuneta. It existed in the first versions and was cut when io_uring came in; what was missing was the wiring, not the design.

Nothing is removed and nothing changes shape: a gclass that does not call it behaves exactly as before. webstats is the first consumer and the only yuno that changed.

Added

Fixed

Changed

7.9.13

A release to carry one agent fix to the nodes. yuno_agent changed, so this is a version bump and not a packaging revision.

It is a version bump for a reason worth stating: the agent is not a managed yuno, so no install-binary reaches it. On a node with no SDK sources the package is the only road to its binary — the same argument that cut 7.9.12.

yuneta_agent22 is untouched and needs no update: the escape hatch exposes tty and consoles only, no config commands. That is what lets a node take the new agent with its second agent still running.

Fixed

7.9.12

A release with one reason to exist: to put the webstats binary in the packages, so the other four nodes can run it. The yuno was written and proved on one node, and a yuno that lives only in a build tree reaches nobody — the .deb and the .rpm ship outputs/yunos/ whole, and that is the road to a node that carries no SDK sources.

Nothing under kernel/, modules/ or utils/ moved, so no running yuno needs a rebuild for this. @yuneta/gobj-js stays at 7.9.11 on npm: this release carries no JavaScript.

Added

Changed

7.9.11-3

A packaging revision, like the one before it: nothing under kernel/, modules/, utils/, yunos/ or tests/ changed, so YUNETA_VERSION stays at 7.9.11 and only the RELEASE counter moves. The packages are rebuilt as yuneta-agent-7.9.11-3 and attached to the existing 7.9.11 tag.

It exists for one reason: the fail2ban filter shipped in 7.9.11-2 could not ban anybody on a single-page app, and a package is the only way that fix reaches a node that installs from scratch. It was found by doing exactly that — imaging two nodes, installing 7.9.11-2 on them and probing them end to end.

Fixed

7.9.11-2

A packaging revision, not a new version of Yuneta: nothing under kernel/, modules/, utils/, yunos/ or tests/ changed, so YUNETA_VERSION stays at 7.9.11 and only the RELEASE counter moves. The packages are rebuilt as yuneta-agent-7.9.11-2 and attached to the existing 7.9.11 tag.

What it ships is the answer to a question nobody had asked of these nodes: what is in their logs. nginx has no rotation of its own, so access.log and error.log had been growing since the day each node was installed — on all five, none had ever been rotated. Reading them for the first time turned up that 99.9% of one node’s error.log was scanner noise, that 43% of all requests were probes for /.env and /wp-login.php, and that nothing was watching any of it.

Added

Fixed

7.9.11

The version skips 7.9.10 on purpose: @yuneta/gobj-js shipped 7.9.10 and 7.9.11 on its own line while the SDK sat at 7.9.9, and the SDK catches up to the package rather than the other way round.

Added

Changed

7.9.9

Fixed

Added

Changed

Documentation

7.9.8

Fixed

7.9.7

Added

Changed

7.9.6

Added

Changed

Fixed

7.9.5

Security

Changed

Added

Fixed

Tools

Documentation

7.9.4

Ships with @yuneta/gobj-js 7.9.4 and @yuneta/gobj-ui 5.4.0.

A kernel fix that had been paid for three times at the call site, and the three ways of showing depth in a node tree become one runtime knob.

Fixed

Changed

7.9.3

Ships with @yuneta/gobj-js 7.8.7 and @yuneta/gobj-ui 5.3.2.

Two auth_bff fixes finish the session-restore path opened in 7.9.1, and the UI library turns navigation into a tree of gobjs.

7.9.2

Install this instead of 7.9.1. On a node coming from 7.9.0 or older, 7.9.1 deletes the web server configuration it was meant to protect. If a node already runs 7.9.1 it is past that transition and is not affected.

7.9.1

A recovery release: after a machine reboot, a node came back with its login wedged and stayed that way. Three defects were behind it, at three layers.

7.9.0

Ships with @yuneta/gobj-js 7.8.7 and @yuneta/gobj-ui 5.2.0.

The headline is a BREAKING change in the agent: delete-yuno no longer deletes a whole yuno by omission. See the entry below for why the old default was the wrong way round.

7.8.7

A release the toolchain asked for. Ubuntu 26.04 brought gcc-15 and clang-21, whose <string.h> hands back a const char * where the code expected a writable one, and the fourteen warnings that surfaced pointed at one place that really was editing memory it did not own. Following that thread through the inter-event layer turned up four identity checks that had drifted apart from each other and from how the framework matches names everywhere else.

7.8.6-4

Another packaging revision — YUNETA_VERSION stays at 7.8.6, only RELEASE moves. Everything here is about what an operator is told when something goes wrong: a clean install on a fast Debian 13 dedicated server failed to install certbot and reported three symptoms and no cause, and a Rocky node reported its firewall work in words that sent the reader to the command that says “FirewallD is not running”.

7.8.6-3

A packaging revision, not a new version of Yuneta: no source under kernel/, modules/, utils/ or yunos/ changed, so YUNETA_VERSION stays at 7.8.6 and only the RELEASE counter moves. The packages are rebuilt as yuneta-agent-7.8.6-3 and attached to the existing 7.8.6 tag.

Revision 2 was withdrawn without being announced: its .deb still came off the ubuntu-22.04 runner, so the archives it ships were stamped glibc 2.35 while Debian 13 runs 2.41. libc_guard.cmake compares the two as an exact string, so that package installs and runs but leaves the node unable to build anything against the SDK it just dropped there. The number is burned rather than reused: two different payloads must never share one version string.

What prompted it: clean installs on Debian 13 and Rocky 9 both finished on a green tick while leaving something broken behind them — a node that would stop answering at its first reboot, certificates that would not renew, and an apt that could not tell whether the payload fit on the disk.

The two install.sh fixes below are listed for the record but were live the moment they were pushed: that script is fetched from main, not from a release asset.

7.8.6

A release about the first minutes of a node’s life. Installing on a clean Ubuntu printed a wall of dependency errors and rescued itself; the two distro families named the same helper differently; and nothing in the docs said that the SDK a package drops on a node can only compile there when the glibc matches — which, on Ubuntu 26.04, it does not.

7.8.5

A release about where things live. The deploy tooling was split across two release channels — the CLI on PyPI, the Python tools inside the packages — and the halves drifted until a pipx upgrade alone could break a deploy. They are one package now. The .rpm likewise stops being built on Ubuntu, and configs stop carrying credentials into git.

7.8.4

A release about a lie the build was telling. A node with a compiler could compile its own yunos against the archives shipped in the package, the link would succeed, and the binary would corrupt its heap the moment it ran. It cost a day of chasing a refcount bug that did not exist — the stack traces pointed at kw_decref and jansson, and both were innocent bystanders. The build now refuses that link instead of producing the binary, and the CLI stops reporting success when the refusal happens.

7.8.3

A packaging release, and a lesson about who else wants your kernel knobs. 7.8.2 fixed /var/crash losing a tug-of-war with kdump; this one fixes core_pattern losing the same kind of fight to Ubuntu’s apport. Validated end to end on an Ubuntu 26.04 node: a deliberate SIGSEGV now leaves a real core in /var/crash, which it did not before.

7.8.2

A release about a failure that could not be seen. A node came up with a black-holed nameserver first in /etc/resolv.conf; every name resolution paid ~6 s, resolution runs synchronously inside the event loop, and a yuno building 25 channels spent ~2 min 40 s in start up — long past the agent’s handshake timeout, so the agent reported it as not running while the process sat there alive and listening. Nothing in any log said any of that. The fixes below are in the order they were needed: a way to trace start up at all, then the fix, then the two warnings that would have made the whole hunt a one-line grep.

7.8.1

A bugfix release. Most of it came out of watching a node come back from a cold machine reboot with its IdP (Keycloak) still booting — the state where half of these paths run for the first time. Two crashes and several noisy-but-harmless log storms were fixed, plus one refcount contract that had been quietly wrong.

No BREAKING changes. Ships with the same @yuneta/gobj-js 7.8.0 and @yuneta/gobj-ui 4.0.0 as 7.8.0 — this release is C-only.

- **fix(auth_bff): a 5xx from the IdP with a non-JSON body logged two errors
  per request.** `send_token_to_browser()` read `error` / `error_description`
  off the response body to map the RFC-6749 error envelope, but that envelope
  only exists when the IdP itself answered. When the IdP is **down**, the 5xx
  body is a reverse proxy's HTML 502 or empty, so `kw_get_dict(kw, "body")`
  returns NULL and the two `kw_get_str()` calls path into NULL —
  `kw_find_path()` logs *"kw must be list or dict"* with a stack trace, twice
  per failed request (seen as 10 of those against 5 `👤BFF server error`).
  The envelope is now read only when the body really is a dict; otherwise
  `idp_err`/`idp_desc` stay `""` and the generic `auth_unexpected_error` (502)
  mapping applies, which is the correct outcome for an IdP outage. The 5xx
  `👤BFF server error` itself is legitimate and unchanged — the operator does
  want to know the IdP is down.

- **fix(iogate): broadcasting to two or more channels double-freed the
  gbuffer.** `send_all()` handed each child `json_incref(kw)` while every
  child `KW_DECREF()`s it, and `kw_decref()` drops the serialized binary
  fields on every call. So the gbuffer took one decref per child plus
  `send_all()`'s own, against the single `kw_incref()` of the caller: the
  arithmetic only worked out with exactly one open channel, and from the
  second on it was a double free (*"BAD gbuf_decref()"*). Now `kw_incref()`.
  `send_one_rotate()` was never affected — it hands its own reference to a
  single channel. Same defect as the `c_task` one below; the rule ("a kw is
  refcounted with `kw_incref`/`kw_decref`, and events always carry a kw") is
  now in CLAUDE.md's API footguns. The remaining `json_incref(kw)` event
  sends in `root-linux` and `utils` (timer, ievent_cli, yuno, authz, ota,
  the three CLIs) are corrected too: none of their kws carries a gbuffer
  today, so they were latent, not live.

- **chore(gobj-c): `load_persistent_json()`'s cannot-open critical now
  carries a stack trace.** It names the file but not who was opening it,
  which is exactly what you need when two processes race for the same
  exclusive lock.

- **fix(agent): a yuno slow to open was launched twice at boot.** The boot
  runs in two sweeps — `run_util_yunos()` for the `yuno_tag=util` yunos,
  then `run_enabled_yunos()` for everything else, `timerStBoot` later — and
  both skip a yuno only when its `yuno_running` is TRUE. That flag is set in
  `ac_on_open()`, i.e. when the yuno registers back, so a yuno that takes
  longer than `timerStBoot` to open is still marked not-running when the
  second sweep arrives and gets launched a second time. `run_yuno()` now
  marks the yuno as launching and both sweeps honor the mark, which
  `ac_on_open()` clears. The `run-yuno` **command** honors it too: it
  guarded on the same `yuno_running`, so an operator launching a yuno that
  was still coming up got a second instance the same way. The mark carries
  its launch time and expires after `timeout_expiration` (30s, the window
  the command counters already give a yuno to connect back): a yuno that
  dies before opening never clears its mark and the agent doesn't watch
  pids, so without the expiry a failed launch would block `run-yuno` for
  that yuno until the agent restarted. This only ever bit at machine boot: on a warm
  `yshutdown` + `restart-yuneta` every yuno registers well inside the
  window. Seen on a controlcenter (a `util` yuno that loads a treedb, unlike
  logcenter/emailsender): the second instance died on timeranger2's
  exclusive `__timeranger2__.json` lock, logging a CRITICAL. That lock is
  what kept two masters off the same store — a `util` yuno without a tranger
  would simply have run twice, with nothing to log.

- **fix(task): forwarding a kw with a gbuffer to an lmethod double-freed the
  gbuffer ("BAD gbuf_decref()").** `C_TASK`'s `ac_on_message()` handed the kw
  to the job's lmethod with `json_incref(kw)`. A kw carrying a serialized
  binary field has its own symmetric pair — `kw_incref()`/`kw_decref()` —
  because `kw_decref()` drops the binary on *every* call, not only on the
  last one. `json_incref()` bumps the JSON refcount but not the gbuffer's,
  so the two `KW_DECREF()`s that follow (the lmethod's and the action's own)
  decref the gbuffer twice against a single incref: it is freed early, and
  the publisher's final `KW_DECREF()` then reads a freed header. Now uses
  `kw_incref()`, like `c_iogate`/`c_qiogate` do when forwarding.
  Only reachable when the kw actually carries a gbuffer, which for the HTTP
  task path means a non-`application/json` response body (`ghttp_parser`
  parses JSON into `kw["body"]` and keeps the gbuffer instead) — in
  practice, an error page from a reverse proxy. Seen in production as 75
  "BAD gbuf_decref()" matching 75 OIDC discovery failures 1:1, while
  Keycloak was still booting and nginx answered 502 + text/html.

- **fix(prot_tcp4h): parsing a buffer whose frame dropped the connection
  raised "Event NOT DEFINED in state".** `ac_process_payload_data()` ends by
  re-sending `EV_RX_DATA` to *itself* to parse whatever is left in the
  buffer. Just above, `frame_completed()` publishes `EV_ON_MESSAGE`
  synchronously — and a subscriber may drop the whole chain from inside that
  publish (an authz NAK, or a peer sending bad json). The FSM is then in
  `ST_DISCONNECTED`, where `EV_RX_DATA` is not defined, so the self-send
  logged an ERROR plus a full stack trace for every such connection.
  `frame_completed()` already knew about this cascade — it guards its own
  `start_wait_frame_header()` with the same state check — but it returns 0
  regardless, so the caller had no way to tell it was already dead. The
  leftover self-send is now skipped once we are `ST_DISCONNECTED`: nobody
  upstream wants the rest of the buffer. The sibling self-send in
  `ac_process_frame_header()` needs no guard — it follows a plain
  `gobj_change_state()` with no publish in between, so the connection cannot
  have died under it.

- **fix(ievent): one garbage packet no longer costs four ERROR entries and
  two stack traces.** A peer sending non-JSON to an ievent port (a port
  scanner is enough) walked a cascade of logs that all described the same
  event: `gbuf2json()` logged "json_load_callback() FAILED" with a full
  stack trace, `iev_create_from_gbuffer()` logged "gbuf2json() FAILED",
  `ac_on_message()` logged "iev_create_from_gbuffer() FAILED", and none of
  them named the peer. All three were `gobj_log_error` — errors are for our
  own broken invariants, and a stranger sending junk is not one.
  `C_IEVENT_SRV`/`C_IEVENT_CLI` now ask for the parse silently
  (`verbose = 0`, which `iev_create_from_gbuffer()` newly honors for its own
  log too) and emit a single `gobj_log_warning` under `MSGSET_PROTOCOL`,
  carrying `peername`/`sockname` and a dump of the offending bytes capped at
  `MAX_LOG_DUMP_SIZE` (256), mirroring `c_prot_tcp4h`. The dump reads from
  the gbuffer head, so it survives the parser having consumed the data and
  leaves the read pointer alone. Behaviour is unchanged: the connection is
  still dropped. Callers passing a non-zero `verbose` keep the old logs.

- **feat(authz): a subscriber of `EV_AUTHZ_USER_LOGIN` can now refuse a
  login.** `mt_authenticate()` published the event fire-and-forget and threw
  away the result, so a subscriber unable to accept the user had no way to
  say so — the login succeeded regardless, and the user was left
  authenticated but unregistered downstream. It now checks
  `gobj_publish_event()`'s return and answers `result: -1` ("Some subscriber
  refusing user") when it comes back negative. Contract note for out-of-tree
  gclasses: an action handling this event that returns a negative value now
  denies the login; every in-tree subscriber (`c_controlcenter`, `c_agent`,
  `c_mqtt_broker`) returns 0 and is unaffected. Two caveats worth knowing:
  the checked value is the *sum* of the subscriber returns, and a subscriber
  holding `__own_event__` short-circuits before the accumulation, so its
  refusal is not seen. The auth failure paths also log `peername`/`sockname`
  now.

- **fix(controlcenter): a login arriving before the service played crashed
  against a NULL treedb.** `C_CONTROLCENTER` subscribes to the authz service
  in `mt_start` but only opens `treedb_controlcenter` in `mt_play`, so every
  login landing in that window called `gobj_get_node()` / `gobj_create_node()`
  with a NULL gobj and logged "hgobj NULL or DESTROYED" with a stack trace —
  loud at startup, when many agents reconnect at once. `ac_user_login` and
  `ac_user_new` now check the treedb is open and refuse the login until the
  service reaches `mt_play` (fail-closed, riding the authz contract above),
  so a user never gets in without its controlcenter record.

7.8.0

A feature release built around the C_TRANGER / C_NODE read surface — the command set a GUI needs to browse a timeranger2/treedb without pulling whole topics into the browser: server-side key filtering/sorting/paging (list-keys), cursor pagination (open-iterator / get-page), realtime feeds (open-rt / close-rt), the restored record-read commands, and print-tranger’s bounded, drillable raw-JSON dump. Alongside it, a timeranger2 audit pass that fixed several ways a failed append could be reported as success.

No BREAKING changes: the one signature correction is a header/implementation mismatch (prototype names only, no ABI change), and the one event-payload change is additive.

Ships with @yuneta/gobj-js 7.8.0 and @yuneta/gobj-ui 4.0.0 (a MAJOR — five BREAKING contract changes; see its CHANGELOG before upgrading a v2 SPA).

- **feat(treedb): C_NODE gains a `print-tranger` command.** Mirrors
  C_TRANGER's: it dumps the tranger the treedb lives on as bounded,
  `kw_collapse()`-truncated JSON, and accepts `path=` to lazily drill into
  one subtree (arrays indexed by numeric position, so the path round-trips
  through `kw_find_path`). This is what the gui_treedb "Raw JSON" viewer
  calls to inspect a treedb's raw tranger; a primitive path returns an
  explicit error rather than a silent null.

- **feat(gobj-c): `kw_collapse()` now accepts a top-level array.** Drilling
  `print-tranger path=<array>` used to fail because `kw_collapse()` required
  a dict at the requested path; it now collapses a top-level array too (new
  `collapse_array`, mirroring the array-value branch of `collapse()` —
  object elements recursed, arrays/primitives copied shallow, element paths =
  numeric index). Dict output is byte-for-byte unchanged, and only
  `print-tranger` calls it. Covered by `tests/c/kw/test_kw1` (top-level-array
  collapse shape + `kw_find_path` round-trip + primitive-rejected).

- **fix(timeranger2): the realtime feeds ignored `only_md`, always reading
  and delivering the record body.** A feed opened with `only_md` wants a
  record's metadata but not its content — the historical iterator honors it,
  but both realtime paths (the master append fan-out to rt_mem lists, and
  `publish_new_rt_disk_records` on a follower) always called
  `read_record_content()` and handed the callback the full body. On rt_disk
  that was an extra disk read per live record, and within a single `only_md`
  list historical rows arrived md-only while live rows carried full content.
  They now honor `only_md`: the only_md feeds get NULL `jn_record`, and the
  rt_disk read is skipped when no audience of the key needs the body. No
  consumer relied on the old behavior (c_tranger already synthesizes an
  md-only record for a NULL `jn_record`; tr_queue and the mqtt broker use
  `only_md` only for one-shot historical dumps). Covered by
  `tests/c/timeranger2/test_rt_disk_multi_feed` (an rt_disk and an rt_mem
  `only_md` feed asserted to receive metadata, never a body).

- **fix(treedb): `treedb_create_node()` could return a dangling pointer.**
  When the primary already existed (`save_id` false) and every listed pkey2
  had no `indexy` (a schema/index inconsistency), the node landed in no
  index, yet the final `json_decref` dropped its only ref and the freed
  pointer was returned. It now frees the node and returns NULL when it
  reached no index.

- **fix(timeranger2): two fs_watcher edge cases.** (1) the inotify read
  buffer leaked when `yev_create_read_event()` failed (which only happens
  before it takes ownership of the gbuffer). (2) after a concurrent
  watch-descriptor removal `get_path()` returns NULL, and every event branch
  except IN_DELETE built a `"(null)/..."` path or handed the callback a NULL
  directory; the stale event is now skipped once, up front.

- **fix(tr2migrate): a failed record append was counted as migrated.** The
  migration callback ignored `tranger2_append_record()`'s return and bumped
  its counters before appending, so a failed append still counted toward the
  migrated totals. It now counts only after a successful append.

- **fix(mqtt): `tr2q_append()` enqueued a garbage entry when the append
  failed** (the broker session queue). Like `trq_append2` it ignored
  `tranger2_append_record()`'s return, building a `q2_msg_t` from an
  uninitialized `md_record` (bogus `rowid`) that later readers would trust;
  and because it had already `KW_EXTRACT`'d the gbuffer out of `kw`, the
  failure path also leaked that gbuffer. It now frees the extracted gbuffer,
  decrefs `kw`, and returns NULL on a failed append.

- **fix(timeranger2): `tranger2_append_record()` reported success after a
  file-open failure.** Both write stages are gated by `if(fd >= 0)` with no
  else, so when `get_topic_wr_fd()` failed for the content or the md2 file
  the function fell through and returned 0 (success) — persisting an index
  row with a bogus offset/size (content open failed), or content bytes with
  no index row and `g_rowid` 0 fed to the realtime feeds (md2 open failed).
  It now returns -1 at either failure, like the sibling lseek/write errors.

- **fix(timeranger2): use-after-free in `fs_watcher` `remove_watch()` under
  `TRACE_FS`.** `path` (the `IN_DELETE_SELF` caller passes `get_path()`'s
  borrowed string, aliasing an entry of `jn_tracked_paths`) was read by the
  trace and by the `inotify_rm_watch` error log AFTER `json_object_del()`
  freed the backing string. It is now snapshotted before the delete.

- **fix(tr_queue): `trq_append2()` enqueued a garbage entry when the append
  failed.** It ignored `tranger2_append_record()`'s return, so on failure it
  built a `q_msg_t` from an uninitialized `md_record` (bogus `rowid`/`__t__`)
  that later readers would trust. It now decrefs `kw` and returns NULL on a
  failed append, matching `tr_msg`/`tr_msg2db`.

- **fix(tr_msg2db): `msg2db_close_db()` could leak the shared descriptor
  with concurrent msg2dbs.** It released `topic_cols_desc` with an
  unconditional `JSON_DECREF` (which nulls the global), so closing one of two
  open msg2dbs nulled the global while the other still held a ref — leaked on
  the second close. It now uses the same refcount-guarded decref as
  `treedb_close_db`.

- **fix(treedb): a failed treedb/msg2db open leaked the shared column
  descriptor.** `treedb_open_db()` / `msg2db_open_db()` incref-or-create the
  module-global `topic_cols_desc` before validating the schema, but the two
  early error returns ("No topics found", "TreeDB ALREADY opened") skipped
  the matching decref — and a failed open is never paired with a
  `close_db()`, so the descriptor stayed alive to `gobj_end()` (a leak under
  `CONFIG_DEBUG_TRACK_MEMORY`, failing ctest). Both paths now undo the
  incref/create with the same refcount-guarded decref `close_db` uses (plain
  `json_decref` while another open still holds it — the double-open case —
  else `JSON_DECREF` to free and null the global). Covered by
  `tests/c/tr_treedb_hook_hygiene` (a no-topics open + the end-of-test memory
  check).

- **fix(timeranger2): a corrupt md2 file was detected, logged, then read
  anyway.** `load_first_and_last_record_md()` logged "md2 file corrupted" on
  a negative or misaligned `lseek(SEEK_END)` but did not return, falling into
  `if(offset >= sizeof(md2_record_t))` — where the signed `offset` is promoted
  to unsigned, so a negative (lseek-error) offset passed the guard and a
  truncated (misaligned) file was read one record short, both feeding a bogus
  "last record" into the key cache. It now closes the fd and returns -1 at the
  corruption point, so the caller aborts instead of caching garbage.

- **fix(treedb): force-deleting a node with an array hook skipped every
  other child and then aborted.** The down-link teardown in
  `treedb_delete_node(force=1)` iterated the parent's hook array while
  `_unlink_nodes()` removed each child from that SAME array in place, so the
  index-based loop stepped over the shifted tail: with three children it
  unlinked the 1st and 3rd, left the 2nd, and the re-check then found a
  leftover down link and refused the delete — leaving a half-unlinked graph
  persisted on disk (the cleared children were already saved) while
  reporting failure. The teardown now snapshots the child refs before
  unlinking. Covered by `tests/c/tr_treedb_hook_hygiene` (force-delete of a
  config with three linked yunos).

- **fix(treedb): `parse_schema()` validated every schema against an EMPTY
  descriptor.** It built a local column descriptor but passed the
  module-global `topic_cols_desc` to `parse_schema_cols()` — and that global
  is NULL until the first `treedb_open_db()`. `parse_schema()` is the
  validate-before-open helper the gclasses call at `mt_start`, i.e. before
  any treedb is open, so `json_array_foreach(NULL, ...)` ran zero checks and
  ANY malformed schema passed unvalidated. It now validates against the
  descriptor it builds.

- **fix(timeranger2): an md2 open failure poisoned the key's cache with a
  ~1.8e19 row count.** `load_first_and_last_record_md()` returns -1 on a
  failed `open()`, but its type was `uint64_t`, so the caller's
  `if(file_rows < 0)` never fired: -1 became `UINT64_MAX` and the cache cell
  was built with that as its row count. A follower reloading a key whose
  `.md2` was unlinked mid-append (`tranger2_delete_key` racing an append)
  then ran `publish_new_rt_disk_records` with `to_rowid = UINT64_MAX`, a
  near-unbounded read loop. The function is now `json_int_t`, the guard
  fires, and both callers bail (or skip the file) on NULL.

- **fix(timeranger2): a deleted key left its watermark behind in every disk
  feed.** `published` holds one mark per key, and nothing dropped it when
  the key died: a keyless feed on a topic that cycles its keys (an hourly
  bucket) kept a mark for every key that ever existed, for as long as it
  lived. Worse, a key RE-CREATED in the same file inherited the corpse's
  mark — and a mark above the reborn key's rowids is a ceiling, not a
  watermark, so its records were served to nobody. The mark now dies with
  the key, on both delete paths (the master's `tranger2_delete_key()` and
  the follower's `FS_SUBDIR_DELETED` branch, which share
  `fire_key_deleted_locally()`). Covered by
  `tests/c/timeranger2/test_rt_disk_multi_feed` (a key deleted and
  re-created in the file its mark counted in).

- **fix(c_tranger): `list-keys` answered an UNSORTED page as if it were
  sorted.** When the sort could not allocate its row array it gave up and
  returned, and the command went on to page the untouched list and answer
  OK: `from`/`limit` then cut it at positions that mean nothing, and the
  client — which had asked for `order` — had no way to tell. The sort now
  says whether it sorted, and the command is refused with the reason.

- **perf(c_tranger): `list-keys` sorts with `qsort`, not an insertion
  sort.** The ordered variant inserted each key by linear scan — O(n²)
  with a jansson refcount round-trip per swap, inside the event loop;
  `order=records` over a topic with enough keys to need `list-keys` at
  all could stall the yuno for seconds. Ties on the record count now
  break by key, so equal counts page deterministically (`qsort` is not
  stable).

- **fix(timeranger2): the first record of every new md2 file reached NO
  realtime disk feed** — a Live card left open across midnight silently
  dropped one record a day, per key. A feed's `published` watermark counts
  rows IN A FILE (`read_md()` seeks `(rowid-1)` records into `<file_id>.md2`,
  and the batch bounds come from that file's cache cell), but the topic
  ROTATES its file (`filename_mask`, by default one a day). The mark was
  stored per key alone, so at the rotation yesterday's mark — say row 226651
  — met a file whose rowids restart at 1 and became a ceiling instead of a
  watermark: `from_disk_rowid` sat above `to_rowid` and the batch was served
  to nobody. It self-healed on the next append (the mark is overwritten with
  the new file's row count), which is exactly why it read as "a record went
  missing" and not as a broken feed. The mark now carries the file it counts
  in: a mark of another file is no mark, and the feed is re-seeded at the
  new file's start.

  With it, the rowid a disk feed is GIVEN is now the GLOBAL rowid of the key
  (the file's base plus the position in it) — what the callback's contract
  promises (*"global rowid of key"*) and what the master's rt_mem path
  already delivered (`update_new_record_from_mem()` returns `g_rowid`). The
  follower's disk path was handing out the file-relative one, so `g_rowid`
  in the published `__md_tranger__` was wrong after the first rotation and a
  consumer that dedupes by it (the Live cards' key counter) took the new
  file's records for records it already had. Covered by
  `tests/c/timeranger2/test_rt_disk_multi_feed` (a rotation phase: the first
  append of the new file reaches each feed exactly once, with the key's
  global rowid).

- **fix(timeranger2): a realtime DISK feed re-broadcast every other feed's
  wake-up, so N feeds on a key meant N copies of every record for each of
  them.** With a per-key Live card and a whole-topic Live card open on the
  same key (gui_treedb), every row appeared DUPLICATED in both. The master
  hard-links each new md2 into the directory of EVERY feed that wants the key
  (`master_to_update_client_load_record_callback`), so a follower is woken
  once per feed — but `publish_new_rt_disk_records()` then fanned the new
  records out to EVERY feed of that key, turning N wake-ups into N x N
  publishes. The wake-up now serves the feed whose `/disks/<rt_id>/`
  directory fired, and nobody else: the `rt_id` was already parsed off the
  path and thrown away. Each feed carries its own `published` watermark per
  key, because the shared key cache advances with the FIRST wake-up and a
  feed served by a later one would otherwise find "nothing new" and lose the
  record; the in-process rt_mem lists of a follower keep being fed from that
  shared cache, so they still see each record exactly once.

  The watermark of every feed of the key is SEEDED at the batch start on
  the batch's first wake-up, while that start is still known: a feed with
  no watermark yet (one that just opened) cannot recover it from the
  already-advanced cache, and unseeded it permanently lost the first
  records after opening — they reached the sibling Live card and never
  that one (on a brand-new key, whose batch starts at rowid 1, BOTH feeds
  lost the first record; presence in `published` is what says "seeded",
  since the legitimate seed there is 0). Covered by `tests/c/c_tranger`
  (a keyed feed and a keyless feed over the same key, one publish each,
  rt_mem on the master) and by `tests/c/timeranger2/test_rt_disk_multi_feed`
  (the rt_disk fan-out itself: master + in-process follower, three feeds,
  first-append-after-open and brand-new-key cases); verified against the
  live backend by counting the WebSocket frames of a browser with both
  cards open.

- **fix(timeranger2): a brand-new key made every rt_disk follower log an
  `unlink() FAILED`.** On staging, `agregador_wz` logged one *"unlink()
  FAILED, errno 2 (No such file or directory)"* per new key — an hourly
  bucket meant an hourly ERROR in the log, and monitors alerting on nothing.

  A follower is notified of new records by a hard link the master drops in
  `<topic>/disks/<rt_id>/<key>/<file>.md2`; the follower unlinks it (consuming
  the notification) and reads what is new. A brand-new key reaches it
  **twice**, by construction: `fs_watcher`, on the `IN_CREATE` of the key
  directory, adds the watch on it **first** and calls back **after**
  (`fs_watcher.c`), so in the window between the two the master hard-links the
  `.md2` inside — and the file then gets its OWN `IN_CREATE`
  (`FS_FILE_CREATED_TYPE`) *and* is found by the directory scan
  (`scan_disks_key_for_new_file`). Both paths lead to
  `update_key_by_hard_link()`; whichever runs second finds the file already
  gone. It is exactly the hazard the inotify(7) note quoted in that handler
  warns about — the scan exists to cover it, but nothing deduplicated what
  the watch would also report.

  `ENOENT` is therefore not an error: it is the notification having been
  consumed already. It is traced (`fs`) instead of logged as an error; any
  other errno still is one. The read still runs — it is idempotent (it
  publishes only the rows the cache does not have yet), so consuming twice
  costs a re-read and nothing else, while skipping it would lose the records
  if the other path had unlinked and then failed.

- **feat(c_tranger): `list-keys` filters, sorts and pages the keys IN THE
  SERVER.** It answered every key of the topic, always: a client that wanted
  the keys of one device was handed a hundred thousand of them and filtered
  what it had been given — and a browser that only shows 15 at a time was
  transferring, holding and sorting the whole index on its main thread.
  New parameters: `rkey` (a PCRE2 regex the key must match), `order`
  (`key`|`records`) + `desc`, and `from`/`limit`.

  Backwards compatible by shape: with no `limit` the answer is the plain
  list it has always been, so every existing client keeps working. Asking
  for a PAGE gets the same envelope `get-page` uses —
  `{total_rows, pages, data}` — so a client pages KEYS exactly as it pages
  records, and `total_rows` counts the MATCHING set, not the page. Sorting
  has to happen here for the same reason: a client holding 15 of 100.000
  keys cannot order what it was not given.

  PCRE2 and not POSIX `regcomp()`: every yuno already links `libpcre2-8`
  (`tools/cmake/project.cmake`) and gobj-c already speaks it
  (`json_replace_vars.c`), and this is the one place in the read path that
  runs the same pattern against up to a hundred thousand subjects — the case
  its JIT exists for. The pattern is compiled and JIT-compiled once, outside
  the loop.

  Cost is paid where it belongs: the record COUNT of a key is a cache lookup,
  so it is taken for the whole matching set (that is what lets `order=records`
  sort by it and `total_rows` be exact), while the key's TIME SPAN copies the
  cache totals into a fresh dict and is therefore built only for the keys
  that actually travel. A bad `rkey` and an unknown `order` are refused with
  an error, never answered as "nothing matched".

- **feat(timeranger2): `rkey` governs a keyless list — the disk load AND the
  realtime feed.** A list opened with no `key` is the whole topic; `rkey`
  narrows it to the keys matching a regex. It was documented and half true:
  the one-shot read (`open-list return_data=1`) filtered the keys, but a LIVE
  list **refused** `rkey` outright (*"rkey is only supported with
  return_data=1"*) — and rightly so, because `tranger2_open_list()` visited
  every key on load and the realtime feeds (`rt_mem` / `rt_disk`) knew
  nothing about it. A list filtered on load and unfiltered on append is worse
  than no filter at all: the caller cannot tell it is being lied to.

  `rkey` is now honoured in both halves, so a live list can finally be opened
  on a SUBSET of a topic's keys. The three dispatch sites that decided who
  gets an appended record (mem feed, disk feed, and the non-master path) went
  through one `list_wants_key()`, so the two halves cannot drift apart again.
  The compiled pattern lives IN the list (a pointer in its json, like its
  `load_record_callback`) and dies with it: it is consulted once per appended
  record, so compiling it per record would cost more than the load it saves.
  PCRE2 + JIT, and `c_tranger`'s one-shot path was moved off POSIX
  `regcomp()` onto it as well — one flavour of regex per parameter, or the
  same `rkey` would mean two different things depending on which half of
  `open-list` ran it.

  A malformed `rkey` REFUSES the list (and the feed), rather than degrading
  to "every key": that would push the caller exactly the records it asked not
  to receive. The refusal is logged as a **warning**, not an error with a
  stack trace: an `rkey` arrives from a remote peer (a SPA opening a live
  list), so a bad pattern is peer input, not a broken internal invariant —
  the decoder-severity policy. `gobj_log_set_last_message()` carries the
  regex's compile error (pattern + offset) into the refusal the caller gets
  back, so the SPA can show WHY the pattern was rejected.

  (An earlier note in this section claimed `open-list`'s `rkey` was *silently
  ignored*. That was wrong, and worth correcting: it was honoured in the
  one-shot read and explicitly refused in the live one. The dead code is
  timeranger2's commented-out `find_keys_in_disk`.)

- **fix(c_tranger): use-after-free closing a handle whose topic was closed
  (SIGSEGV on shutdown).** A topic OWNS the iterators / rt_mem / rt_disk
  handles opened on it: `tranger2_close_topic()` closes them all
  (`tranger2_close_all_lists`) and frees the topic. C_TRANGER registered
  what a remote client opened as a RAW POINTER, so once anything closed a
  topic — the app closing a treedb at shutdown, a `delete-topic` at runtime
  — its registry pointed at freed memory, and the next close dereferenced
  it. In production this crashed a yuno with SIGSEGV on EVERY shutdown that
  had a Rows card open: close-treedb frees the topics, then C_TRANGER's
  `mt_destroy` walked its registry calling `tranger2_close_iterator()` on
  iterators that no longer existed (jansson reading a dead hashtable). The
  daemon then relaunched the yuno, so its binary was never idle and
  `update-binary` failed with `copyfile() FAILED` — an un-updatable yuno was
  the visible symptom. The registry now stores the topic name beside the
  pointer and every use goes through a guard (new
  `tranger2_topic_is_open()`): topic gone ⇒ the handle was freed with it ⇒
  drop the entry, never touch it. Regression test: open an iterator + a feed,
  close the topic under them, then command and tear down — it SIGSEGVs
  without the guard.

- **fix(timeranger2): the cache totals of a key stored a timestamp with the
  metadata flags baked into it.** In an on-disk `md2_record_t` the 16 high
  bits of `__t__` carry the `user_flag` and those of `__tm__` the
  `system_flag`. The disk-load path masks them off; the APPEND path did not,
  and fed the raw record to `update_cache_cell()` — so on the master, a key's
  cached `fr_t`/`to_t`/`fr_tm`/`to_tm` were poisoned by the flag bits (a `tm`
  of 946684800 was cached as 17593132729216). This was not cosmetic:
  `get_segments()` chooses which `.md2` files to read by comparing against
  those very ranges, so **file selection by time was wrong for any record
  carrying a non-zero user_flag or system_flag** (treedb tagged records,
  queue pending flags). `update_cache_cell()` now reads the times through the
  `get_time_t()` / `get_time_tm()` accessors. `test_topic_pkey_integer`'s foto
  had the defect baked in — it held BOTH forms of the same value (polluted in
  the in-memory cache, clean after a reload) and has been repointed.

- **feat(timeranger2): a filtered paging iterator now honors its
  `match_cond` per RECORD (row index).** `get_segments()` can only reason
  about whole files, and `tranger2_iterator_get_page()` ignored the
  iterator's conditions entirely (it built its own `match_cond` with just
  `from_rowid`/`to_rowid` and reported the FULL key row count), so the
  time / rowid / user_flag conditions of `open-iterator` were only applied at
  file granularity: a 4-record key filtered down to 2 still reported 4 and
  its page returned all 4. An iterator that filters now builds its row
  **index** when it opens (`tranger2_match_metadata` over each record's
  32-byte metadata, no content read): `tranger2_iterator_size()`, `pages` and
  the pages themselves count only matching records, and `get_page`'s
  `from_rowid` is a position among THOSE rows. An unfiltered iterator builds
  no index — its open stays O(1) on the key size and its positions remain the
  global rowids. A dead row (`tranger2_delete_instance`) never enters the
  index, so a filtered iterator's count no longer over-reports it.

- **feat(c_tranger): `list-keys` reports each key's time span, and `topics
  expanded=1` its topic descs.** `list-keys` now returns `fr_t`/`to_t` and
  `fr_tm`/`to_tm` alongside `records`, so a client can bound a time picker to
  what the key actually holds without reading a record;
  `topics expanded=1` returns a desc per topic (`topic_name`, `system_flag`,
  `pkey`, `tkey`) — `system_flag` is the only thing that says whether the
  topic's `t`/`tm` are seconds or milliseconds. Both are additive: the
  default `topics` answer (an array of names) is unchanged. New public
  `tranger2_topic_key_range()`.

- **fix(timeranger2): `get-page` reported "0 pages" for an out-of-range page.**
  `pages` is a property of the KEY and the requested page size, not of the
  particular answer: a page past the end has no data, but the key still has
  its pages. Returning 0 told the client "there is nothing at all" and
  collapsed its pager to a single page — a remote-paginated table then
  refused to move ("Next page would be greater than maximum page of 1") and
  its Last button landed on an empty table. Both empty branches
  (out-of-range `from_rowid`, no first segment) now report the real page
  count.

- **fix(c_tranger): realtime feeds leaked, and every leaked feed duplicated
  records for EVERY subscriber.** `publish_rt_callback()` runs once per OPEN
  FEED, and each run published `EV_TRANGER_RECORD_ADDED` to the whole
  channel. Feeds were only ever released by an explicit `close-rt` (or at
  `mt_stop`), so a remote client that died without one — browser reload,
  closed tab, dropped websocket — left its feed alive forever. Every
  surviving feed then re-published each append, so N leaked feeds meant N
  copies of the same record delivered to all subscribers, not only to the
  session that leaked them (observed in gui_treedb: the same `rowid`
  repeated ~20 times in a Live card).
  Two fixes: (1) the payload now carries the `rt_id` of the feed that
  produced it, so a record can be routed to the subscriber that OPENED that
  feed instead of broadcast (a foreign feed can no longer duplicate anyone's
  rows); (2) `mt_subscription_deleted` closes the feeds **and iterators**
  opened by a subscriber once its last subscription is gone — the owner is
  stamped into the rt/iterator as `src_gobj` at `open-rt` / `open-iterator`.
  Consumers of the event get one new field (`rt_id`) and are otherwise
  unaffected; a consumer that does not filter on it keeps working.
  An iterator opened by a session that never subscribes (Rows-only browsing)
  is still only reclaimed at `mt_stop` — see `TODO.md`.

- **feat(c_tranger): `open-iterator` accepts metadata match conditions.**
  Beyond `key` + `backward`, the command now forwards the record-metadata
  conditions honored by `tranger2_match_metadata` into the iterator's
  match_cond, so they pre-filter the page index and the reported
  `total_rows` / pagination reflect the filtered set: `from_t`/`to_t`
  (t, epoch seconds), `from_tm`/`to_tm` (tm, epoch ms),
  `from_rowid`/`to_rowid` (1-based; negative = from end) and the user_flag
  conditions (`user_flag`, `not_user_flag`, `user_flag_mask_set`,
  `user_flag_mask_notset`). Each is optional — `0`/empty means unset. Only
  keys actually supplied are added to match_cond. Record-FIELD filters
  (e.g. `voltage > 200`) are deliberately NOT plumbed here: they are not
  indexable at the metadata level, so they stay client-side in the SPA.
  `open-rt` is unchanged — the realtime feed filters only by `key` (its
  stored match_cond is not consulted on append), so no non-functional
  params were added there. Enables gui_treedb's Rows-card request options
  (yunos-js).

- **fix(timeranger2): fail loud on inotify `IN_Q_OVERFLOW`.** Under a burst
  the kernel drops inotify events and emits a single `IN_Q_OVERFLOW`
  (`wd == -1`); until now `fs_watcher` ignored it, so a dropped
  `FS_FILE_CREATED`/`FS_SUBDIR_*` left an rt-disk follower silently out of
  sync. `fs_watcher` now logs it `critical` with `LOG_OPT_ABORT`: the yuno
  aborts and ydaemon relaunches it, and the clean reload re-establishes
  every feed correctly — the proven recovery path, chosen over a
  hard-to-test in-place resync. The deb/rpm packagers also raise the
  inotify sysctl provisioning in `99-yuneta-core.conf`:
  `fs.inotify.max_user_instances` 1024 → 4096,
  `fs.inotify.max_user_watches` → 524288, and
  `fs.inotify.max_queued_events` → 65536 (a defensive cushion above the
  16384 kernel default), so the abort/relaunch stays rare.

- **feat(c_yuno): `info-inotify` command** — reports the system inotify
  limits (`/proc/sys/fs/inotify/*`) and this yuno's own usage (instances +
  watches, via `/proc/self/fd` + `fdinfo`), alongside `info-cpus` /
  `info-ifs` / `info-os`. The `/proc/self/fd` probing lives in
  `helpers.c` as `get_inotify_self_usage()`.

- **feat(c_tranger): realtime feed commands `open-rt` / `close-rt`, and
  `EV_TRANGER_RECORD_ADDED` is now `EVF_PUBLIC_EVENT`.** `open-rt {rt_id,
  topic_name, key}` opens a realtime-only feed on a topic key — NO history
  load, no data retention — that streams each NEW append to subscribers as
  `EV_TRANGER_RECORD_ADDED {topic_name, key, rowid, record}` (the record
  carries a `__md_tranger__` with the same field names `get-page` emits, so
  live and paged rows render identically). rt-by-memory on the master
  (fires on `tranger2_append_record`), rt-by-disk on a reader (inotify).
  `close-rt {rt_id}` closes it (via `tranger2_close_list`, which dispatches
  by list_type). Feeds live in a per-gclass registry closed at `mt_destroy`
  if a client never sends `close-rt`. Making the event `EVF_PUBLIC_EVENT`
  is what lets a remote subscriber (a browser SPA) subscribe over the
  ievent gate (`c_ievent_srv` requires the flag). The regression test
  (`tests/c/c_tranger/`) subscribes a probe and asserts a fresh append
  publishes exactly once, a cross-key append does not reach the feed, no
  publish after `close-rt`, and a left-open feed is leak-free at destroy.
  Enables gui_treedb's Live records card (yunos-js).

- **fix(timeranger2): three defects found while documenting the public API.**
  (1) `tranger2_topic_key_size()` with an empty `key` passed `gobj` where a
  `json_t *tranger` is expected, so the whole-topic fallback silently
  returned 0; now it returns the real total. (2) `tranger2_close_all_lists()`
  was declared `(…, rt_id, creator)` in the header but the implementation and
  both callers use `(…, creator, rt_id)`; the header (and the `kernel/c`
  README signature) are corrected to match — a caller trusting the old header
  filtered by the wrong field (prototype names only, no ABI change).
  (3) `tranger2_get_iterator_by_id()` / `_get_rt_mem_by_id()` /
  `_get_rt_disk_by_id()` tested `empty_string(creator) && empty_string(creator)`
  (the second operand was meant to be the stored `creator_`); an empty
  query-creator now matches only creatorless entries (both-empty) instead of
  any creator. The three functions also normalize a NULL query-creator to
  `""` (like `tranger2_open_rt_mem()` does), so a NULL `creator` can no
  longer reach `strcmp(NULL, …)` when the entry has a creator.
  Also: the whole `timeranger2.h` public API was re-documented
  (ownership, return semantics, NULL/error paths, master-only, disk-vs-memory)
  and the doc.yuneta.io timeranger2 API page synced.

- **feat(c_tranger): restore the record-read commands `open-list`,
  `get-list-data` and `close-list`** — v7-port stubs ("Pending to review")
  rewired to the current timeranger2 iterator/list API, keeping the v6
  contract: `open-list` accepts the full match_cond parameter set
  (`key`, `rkey`, `from/to_rowid` incl. negative-from-end, `from/to_t(m)`,
  `fields`, `only_md`, `backward`, user-flag masks). With `return_data=1`
  it is a ONE-SHOT snapshot read — loads the matching records per key with
  short-lived iterators and auto-closes (a remote client, e.g. a SPA, may
  never send `close-list`); without it the list stays open (registry in
  the gclass, closed at destroy), collecting history + realtime appends in
  its `data` (readable via `get-list-data`) and publishing appends as
  `EV_TRANGER_RECORD_ADDED`. `only_md` records are synthesized md-only
  dicts (the current loader hands a NULL content record). Bool/int params
  are read with `KW_WILD_NUMBER` (command-yuno/-agent forward strings).
  `add-record` stays stubbed (write path). Consumed by gui_treedb's new
  tranger records browser (yunos-js repo).

- **feat(c_tranger): cursor-pagination command surface — `list-keys`,
  `open-iterator`, `get-page`, `close-iterator`.** Exposes the timeranger2
  per-key iterator primitives (`tranger2_open_iterator` +
  `tranger2_iterator_get_page`) as commands so a remote client can page
  through a key's records with a real cursor instead of re-reading a
  growing snapshot. `open-iterator` builds the key's row index only (no
  upfront record load, no realtime feed — `load_record_callback` NULL) and
  returns `{iterator_id, total_rows}`; `get-page from_rowid=<1-based>
  limit=<n> [backward=1]` returns `{total_rows, pages, data}`, reading
  records lazily; `close-iterator` closes and deregisters. Open iterators
  live in a per-gclass registry (mirror of the open-lists one) and are
  closed at `mt_destroy` if a client never sends `close-iterator` (a leaked
  iterator would retain file handles). `list-keys` returns a topic's keys
  with their record counts (`[{key, records}]`) — the input for a
  two-level keys→records browser. All check `read`/`list` authz; ints/bools
  read with `KW_WILD_NUMBER`; the parameter is named `iterator_id` (not
  `id`) to dodge the command-yuno `id=` collision. New regression test
  `tests/c/c_tranger/` drives the four commands through `gobj_command`
  (forward paging, out-of-range, dup-open, close-then-404, missing key /
  topic, and a deliberately-left-open iterator proving the destroy-time
  cleanup is leak-free). To be consumed by gui_treedb's two-level tranger
  records browser (yunos-js repo, Phase 2).

7.7.2

C / SDK patch release — agent TTY console lifecycle: open-console re-attach (the gui_agent Terminal shell now survives a browser refresh instead of leaking a PTY per reload), max_consoles off-by-one, and clean shutdown with consoles open. Pairs with the gui_agent stable per-tab console name + screen restore (tracked in the yunos-js repo CHANGELOG); @yuneta/gobj-js is 7.7.2 on npm.

- **fix(agent/agent22): `open-console` re-attach — a browser refresh of the
  gui_agent Terminal no longer accumulates PTYs until `max_consoles`.**
  Re-opening an EXISTING console from the same channel (the
  controlcenter↔agent route, which stays up across browser refreshes, so
  the agent never sees the client disconnect) answered `-1 "Console
  already open"` and each refresh had to fork a new console. Now it is a
  re-attach: the stored route `__md_iev__` is refreshed so the tty stream
  routes to the NEW requester (not the dead one), and `EV_TTY_OPEN` is
  replayed to the requester with the same kw shape C_PTY publishes at
  start (name/process/uuid/cwd/rows/cols) so the client leaves
  "Connecting…" — the shell session survives the refresh. Also fixed the
  `max_consoles` off-by-one (`>` → `>=`: the limit admitted max+1
  consoles). Pairs with the gui_agent stable per-tab console name
  (yunos-js); both `c_agent.c` and `c_agent22.c`.
- **fix(agent/agent22): close live consoles on shutdown.** With a console
  open, an orderly exit (Ctrl-C) reached `gobj_end` with the C_PTY still
  started — "Destroying a RUNNING gobj" + "hgobj destroying" + a running
  `YEV_READ_TYPE` event destroyed hot. `mt_stop` now deletes every entry
  of `list_consoles` (the volatil C_PTY services stop before the tree is
  destroyed). Note the key-aliasing trap: `delete_console` KW_EXTRACTs the
  console from `list_consoles`, freeing the dict key mid-call, so the
  loop passes a copy of the name.

7.7.1

C / SDK patch release — control-plane / auth-BFF hardening and correctness fixes, plus an mbedTLS backend bump. JavaScript framework changes are tracked in their own repositories (@yuneta/gobj-js, @yuneta/gobj-ui CHANGELOGs); the gui_agent / gui_treedb yunos record their own UI changes under yunos/js/*/README.md.

- **chore(ext-libs): bump mbedTLS 4.1.0 → 4.2.0 (v1.21).** The mbedTLS
  TLS backend (runtime-selectable, statically linked into every yuno built
  with `CONFIG_HAVE_MBEDTLS`) moves to the `v4.2.0` upstream tag;
  `repos2clone.sh` re-pins `TAG_MBEDTLS` and `configure-libs.sh` bumps the
  ext-libs `VERSION` 1.20 → 1.21. Rebuild ext-libs (`extrae.sh` +
  `configure-libs.sh`) and relink the affected yunos.
- **harden(c_auth_bff):** cookie/token paths sized and fail-closed. One
  `BFF_TOKEN_MAX` (8 KB) now covers the stored refresh_token, the cookie
  extraction buffers and the Set-Cookie build (a Keycloak access_token with
  many roles exceeds the old 4 KB extraction buffer — `/auth/token` would
  forward it silently clipped and remote backends rejected the signature with
  no evidence). `make_set_cookie` refuses to emit a truncated cookie (logs +
  returns NULL; the login/refresh answer becomes `500 token_too_large`),
  `extract_cookie` treats a value that doesn't fit as missing (clean 401
  instead of a corrupted token), and `/auth/token` checks its `json_pack`.
- **fix(c_authz):** `create-user` / `update-user` no longer report success
  when the treedb write failed — the `EV_ADD_USER` result is propagated, so a
  failed autolink (bad `roles^ROLE^users` string) or tranger write error
  answers `Can't create/update user`. Also: "User not exist" → "User does not
  exist", and `update-user` shares `pm_create_user` (the table was a verbatim
  copy).
- **fix(yuno_agent):** a `write-tty` naming a console that no longer exists
  now answers the requester with a synthetic `EV_TTY_CLOSE` (same path as
  `ac_tty_close`), so a client whose original close was lost in a link flap
  can close its Terminal tab instead of typing into the void. `multiple_dir`
  logs on snprintf failure/truncation (a truncated tags path silently placed
  the yuno under a wrong repos dir). `ac_stats_yuno_answer` gains the same
  self-name short-circuit as its command twin (no more "Event NOT DEFINED"
  noise when the agent itself is the stats requester).
- **fix(prot):** restore the `gobj_has_bottom_attr()` guard on the
  peername/sockname log reads in `c_prot_tcp4h` / `c_ievent_srv` — the bare
  `gobj_read_str_attr()` logged "Attribute NOT FOUND" + stack trace in the
  race windows where the bottom chain is unset (dropped by 390b8c679, which
  overlooked that internal log).
- **fix(glogger):** the `TRACE_GBUFFERS` pretty-print also accepts
  `[`-rooted JSON arrays (they fell through to the hex dump).
- **chore(trace samples):** drop the `"monitor"` / `"event_monitor"` lines
  from the commented trace blocks in `ycommand` and the yuno skeletons — those
  global levels don't exist (uncommenting yielded "global trace level NOT
  FOUND"); the stale `gobj.h` name list is now synced with
  `s_global_trace_level`.

- **feat(c_auth_bff):** new opt-in `POST /auth/token` endpoint returns the
  access_token to JavaScript so a single SPA can forward it in a `C_IEVENT_CLI`
  identity_card to Yuneta backends on **other** hosts (multi-backend browsing,
  e.g. `gui_treedb`). A deliberate, scoped SEC-06 relaxation, off by default and
  double-guarded: `expose_access_token` attr (default `false`; when off the
  endpoint is an invisible `404`) **and** fail-closed Origin pinning (the token
  is emitted only when the request `Origin` exactly matches `allowed_origin`,
  else `403 origin_not_allowed`; enabling the flag without pinning an origin
  yields nothing). Every existing BFF (wattyzer, estadodelaire, hidraulia) keeps
  full SEC-06 — they never enable the flag. The server side already accepts an
  identity-card JWT with priority over the cookie (`c_ievent_srv.c`) and
  validates it against the issuer JWKS (`c_authz.c`); each remote backend must
  have that JWKS provisioned. Docs: `YUNO_AUTH.md` §2.2.
- **fix(c_ievent_srv):** `ac_mt_command`'s "Service not found" error path
  answered with `EV_MT_STATS_ANSWER` (copy-paste from the stats handler)
  instead of `EV_MT_COMMAND_ANSWER`, so a remote command to an unknown service
  got its error back under the wrong answer event.
- **fix(c_tcp):** a running client dropped via `EV_DROP` could stall in
  `ST_STOPPED` (the reconnect `EV_TIMEOUT` was then ignored) and never
  reconnect; `try_to_stop_yevents()` now finalizes to `ST_STOPPED` only when the
  gobj is actually stopping. Generic to every C_TCP client dropped while alive.
- **fix(c_pty):** `EV_TTY_CLOSE`'s `json_pack` had a stray extra `}` → NULL kw
  (no `name`/`uuid`/`slave_name`), so consumers keying off the console name
  (e.g. gui_agent's Terminal) never saw a usable close.
- **fix(controlcenter):** `write-tty` now matches a node by UUID **or** hostname
  (like `command-agent`) and, on a no-match, logs instead of `EV_DROP`-ping the
  requester's shared control socket.
- **fix(controlcenter):** the `write-tty` no-match log is now a **warning**
  (`MSGSET_PROTOCOL`), matching the agent side's demotion of the same benign
  client race — an agent briefly disconnecting while a Terminal tab is focused
  no longer emits one ERROR per keystroke into logcenter.
- **fix(glogger):** `gobj_trace_json`'s `TRACE_GBUFFERS` pretty-print path
  parsed the gbuffer with `string2json(…, verbose=TRUE)`: a gbuffer starting
  with `{` that is not one complete JSON value (partially-consumed buffer,
  back-to-back messages, binary starting `0x7B`) injected a spurious
  `gobj_log_error` + stack trace from a pure trace path. Now non-verbose — the
  existing hex-dump fallback covers the parse failure.
- **fix(yuno_agent):** `write-tty` no longer drops the whole control link on a
  benign per-write error ("console not found" → warning); it logs and drops just
  that message.

7.7.0

C / SDK release — capability marker. No C/SDK source change since 7.6.8; this minor advertises the agent version boundary from which a controlcenter can drive the command/stats control plane of a node’s managed yunos. JavaScript framework packages (@yuneta/gobj-js, @yuneta/gobj-ui) are unchanged this cycle; the gui_agent yuno records its own UI changes in yunos/js/gui_agent/README.md.

- **Controlcenter-driven `command-yuno` / `stats-yuno` of managed yunos —
  minimum agent version is now `7.7.0`.** The agent-side plumbing that
  returns a controlcenter-cascaded answer to the original requester
  (SPA → controlcenter → agent → yuno, back again) shipped in 7.6.8: the
  agent hands the answer to its outbound `controlcenter` `C_IEVENT_CLI`,
  which serializes the inner inter-event via its `EV_SEND_IEV` action
  exactly as `C_CHANNEL` does server-side
  (`kernel/c/root-linux/src/c_ievent_cli.c`, `yunos/c/yuno_agent/src/c_agent.c`).
  7.7.0 **promotes this to an advertised compatibility boundary**: the agent
  reports `YUNETA_VERSION` as its `__version__`
  (`yunos/c/yuno_agent/src/main.c`: `APP_VERSION = YUNETA_VERSION`), so a
  controlcenter can gate on `agent >= 7.7.0` before routing commands/stats
  down to a node. An agent below 7.7.0 must not be assumed to answer these
  cascaded control-plane calls.
- **chore(gui_agent): controlcenter web console rollup.** The console that
  consumes this capability matured into the `yunos-js` submodule: a live
  **Stats** panel per selected node, console command **history**,
  command **shortkeys** (ycli parity) managed from Preferences, a
  **copy-response** button, and TreeDB removed from the console. Tracked in
  `yunos/js/gui_agent/README.md`.
- **chore(repo): `yunos/js` extracted into the `yunos-js` submodule**
  (`github.com/artgins/yunos-js`, tracks `main`). It sits at its original
  path, so the yunos' local `@yuneta/gobj-js` / `@yuneta/gobj-ui` `file:`
  deps resolve unchanged. Edit in `yunos/js`, commit on `main` in the
  standalone repo, then bump the submodule pointer here — the same flow as
  `gobj-js` / `gobj-ui`.
- **chore(cli): yunetas CLI `0.12.0`** — pre-build ext-libs version guard,
  published to PyPI.

7.6.8

C / SDK release. JavaScript framework changes are tracked in their own repositories (@yuneta/gobj-js, @yuneta/gobj-ui CHANGELOGs); the gui_agent yuno records its own UI changes in yunos/js/gui_agent/README.md.

- **fix(agent): controlcenter-cascaded `command-yuno` / `stats-yuno`
  answers now return to the requester.** A command routed down from a
  controlcenter (SPA → controlcenter → agent → target yuno) reached the
  yuno and produced its answer, but the agent dropped it on the way back
  (`ac_command_yuno_answer` / `ac_stats_yuno_answer` logged
  `child not found`), so the caller only saw the synchronous dispatch ack.
  The reverse hop resolved the requester **only** among `__input_side__`
  children; a command that arrived over the agent's outbound
  `controlcenter` `C_IEVENT_CLI` link has its requester on the client side,
  not there. The answer is now handed to that `C_IEVENT_CLI`, which gained
  an `EV_SEND_IEV` action (`kernel/c/root-linux/src/c_ievent_cli.c`) that
  unwraps and serializes the inner inter-event exactly as `C_CHANNEL` does
  on the server side — one uniform path for both server- and
  client-initiated links, covering command and stats answers alike
  (`yunos/c/yuno_agent/src/c_agent.c`).
- **feat(authz): add `update-user`, split from `create-user`.**
  `create-user` already rejected an existing user, so there was no way to
  modify one: `create-user` now cleanly creates (reject if exists) and the
  new `update-user` modifies (reject if missing); both funnel through
  `EV_ADD_USER`. Two latent defects fixed while reviewing the new path
  (`kernel/c/root-linux/src/c_authz.c`): a password-less update no longer
  wipes stored credentials or silently re-enables a disabled account
  (`disabled` is written only when supplied or the user is new), and the
  existence-check node is no longer leaked. The `ROLE` format
  (`roles^ROLE^users`) is now spelled out in the command help.
- **chore(ext-libs): bump jansson 2.15.0 → 2.15.1 (v1.20).** Patch release,
  no API/ABI change. Caps recursion depth in `json_dump` / `json_equal` /
  `json_deep_copy` (anti-DoS hardening on functions every `kw`
  serialize/compare/copy runs through), rejects a negative string length in
  the `json_pack` `s#` / `+#` formats, and adds the offending key/index to
  `json_unpack` type-mismatch errors. `libjansson.a` links statically into
  every yuno, so all must be rebuilt + relinked.
- **chore(ext-libs): bump liburing 2.14 → 2.15 (v1.19)**. Pin-only — no
  API/ABI change and no removed/renamed symbols, so no yuneta consumer,
  header or CMakeLists change rides along. Two of the 2.15 bug fixes land
  on the exact APIs the event loop uses (`kernel/c/yev_loop/src/yev_loop.c`):
  `io_uring_peek_cqe()` drops out-of-line round trips and a redundant
  acquire ordering (loop drain path), and a stale-CQE-pointer fix on
  wait-with-timeout errors (`io_uring_wait_cqe_timeout` in the wait path).
  The new 2.15 helpers (`register_bpf_filter` / `register_query` /
  `register_zcrx_ctrl`) are additive and unused here. `liburing.a` is
  linked statically into every yuno, so yunos that link `yev_loop` must be
  rebuilt + relinked (`extrae.sh` + `configure-libs.sh`) to pick it up.
- **chore(trace): richer unknown-event drop diagnostics + JSON-gbuffer
  pretty-print.** On the empty-`iev_event` drop path both `C_IEVENT_SRV`
  and `C_IEVENT_CLI` now dump the offending kw via `gobj_trace_json` — the
  informative error is already logged upstream by
  `gclass_find_public_event`, so the CLI's duplicate `gobj_log_error` was
  removed. `gobj_trace_json` also pretty-prints a JSON gbuffer under
  `TRACE_GBUFFERS` instead of a hex dump (and decrefs the parsed value, so
  the trace path no longer leaks). (`kernel/c/gobj-c/src/glogger.c`,
  `kernel/c/root-linux/src/c_ievent_{cli,srv}.c`)

7.6.7

- **fix(security): close three buffer/parse defects found in a source
  audit.** (1) `release-packages.yml` interpolated `github.event.release.
  tag_name` / the `workflow_dispatch` input straight into a `run:` shell
  body — an actor with write access could inject commands via a crafted tag;
  the values now flow through `env:` and are referenced as quoted shell
  variables. (2) `c_auth_bff`'s `make_set_cookie()` reused `snprintf`'s
  return value without clamping, so an oversized token value (truncation)
  drove the later `buf+n` / `sizeof(buf)-n` offsets past the stack buffer —
  an out-of-bounds write in the auth path; `n` is now clamped to
  `[0, sizeof(buf)-1]`. (3) `c_agent`'s `multiple_dir()` advanced
  `p += ln; bflen -= ln;` on the `snprintf` return without a truncation
  check, so a domain component that overflowed the buffer sent `p` past the
  end and `bflen` (int) negative — widening to a huge `size_t` in the next
  `snprintf`; it now breaks on truncation.
- **harden: route every `strtok` through `strtok_r`; add split-helper
  tests.** `get_cpus()` (`c_yuno`), `split2()` (`helpers`) and
  `json_unflatten_dict()` (`kwid`) relied on `strtok`'s hidden static state
  — not a live bug (single-threaded, self-contained parses) but a
  reentrancy footgun, notably for the public `split2()` (16 call sites). All
  now use `strtok_r` with a local saveptr; no behaviour or signature change.
  Added `tests/c/helpers/` (split2 + a reentrancy regression asserting
  split2 no longer clobbers a caller's in-progress `strtok` parse) and a
  `json_unflatten_dict` case in `tests/c/kw`. The vendored `linenoise.c/.h`
  reference snapshot was refreshed (it is non-compiled — the console uses
  `c_editline`) and `modules/c/console/README.md` now documents that.
- **fix(js): bump `@yuneta/gobj-js` submodule to 7.6.7 — restore the
  `EV_ON_CLOSE`-on-deliberate-stop contract in `c_ievent_cli`.** 7.6.6
  nulled the WebSocket `.onclose` handler in `mt_stop()`, so a deliberate
  stop never delivered the async `EV_ON_CLOSE` to the FSM and never
  published it to subscribers — diverging from the C kernel (where
  `mt_stop` stops the bottom transport and `ac_on_close` publishes
  `EV_ON_CLOSE` when a session was open). Consumers that drive their logout
  UI teardown from that event (estadodelaire) stopped hiding the app. 7.6.7
  keeps `.onclose` wired and instead guards the handler with
  `gobj_is_destroying()`, so only the stop+destroy-in-the-same-turn case
  (gui_agent) bails out silently while a stop that keeps the gobj alive
  still gets its `EV_ON_CLOSE`. wattyzer/gui_agent (explicit teardown) are
  unaffected. gobj-js-only patch: `YUNETA_VERSION` stays 7.6.6.
- **refactor(decoders): protocol decode errors caused by a malformed packet
  from the peer are now warnings, not errors.** Across the protocol gclasses
  (`c_prot_tcp4h`, `c_websocket`, `ghttp_parser`, `c_prot_mqtt`,
  `c_prot_mqtt2`) a malformed/unexpected frame from the peer logs as
  `gobj_log_warning` with its assigned category (`MSGSET_PROTOCOL`, or
  `MSGSET_MQTT` for mqtt) plus a length-capped dump of the offending frame
  (`MAX_LOG_DUMP_SIZE`, 256). MQTT uses a single capped dump at the
  `frame_completed()` dispatch chokepoint, and the peer-malformed warnings
  no longer carry `LOG_OPT_TRACE_STACK`. This also covers `mqtt_read_string`'s
  "malformed utf8" path (both gclasses), previously mis-tagged
  `MSGSET_INTERNAL` with a stack trace: a peer sending an invalid-UTF8 string
  field is peer-malformed, now a `MSGSET_MQTT` warning with a capped dump of
  the offending bytes. Reserved for our own faults
  (`gobj_log_error`): broken internal invariants (e.g. `c_ievent_srv`'s
  "gbuffer NULL", where the gbuffer must arrive in the `kw`), allocation
  failures, outgoing-encode paths, and unsupported/unknown protocol fields
  ("NOT IMPLEMENTED"/"NOT FOUND") that mark our own TODO/map-gaps. The point:
  error logs should mean "our fault", so routine peer misbehaviour stops
  polluting error counts. Test `c_mqtt/malformed` updated to expect the
  rejection as a warning.
- **feat(agent): report each binary's on-disk file time in
  `*list-binaries` / `*list-binaries-instances`.** Both commands now add
  `time` (epoch seconds) and `time_str` (local timestamp) next to `size`,
  computed live by `stat()`ing the stored `binary` path (no treedb schema
  change; covers binaries installed before the field existed; never mutates
  the in-memory node — the listed records are fresh `node_collapsed_view`
  dicts). The motivation is `sync_binaries`: `size` alone calls a rebuild
  that kept the byte count identical (a one-char log edit, or a relink
  against a changed static lib) "up-to-date", so it was never offered for
  `update-binary`. Added `add_binary_file_time()` in `c_agent.c`.
- **fix(sync-binaries): detect a same-version rebuild by file time, not just
  size.** `classify()` now flags a `REBUILD` when the local file is newer
  than the agent's installed slot even when `Δsize` is 0: it prefers the
  numeric `time` (file mtime) the agent reports next to `size`, and falls
  back to the embedded build `date` (`__DATE__ " " __TIME__`, in
  `--print-role` / `*list-binaries`) for an older agent. The candidate table
  gains a `note` column spelling out a date-triggered rebuild ("newer
  build") so a 0-`Δsize` `REBUILD` doesn't read as a no-op. `tools/README.md`
  updated. The same newer-than-slot check now also applies in the snap-pinned
  (`INSTALLED`) branch.
- **refactor(ytls): clarify the rejected-handshake log line (both backends).**
  The default-on `gobj_log_warning` in `do_handshake` dropped the misleading
  parenthetical hint from its `msg` — OpenSSL's
  `(check ssl_min_version for legacy peers)` and mbedTLS's
  `(mbedTLS floors at TLS1.2; use OpenSSL backend for legacy peers)` both now
  read just `TLS handshake rejected`. The hint implied every rejection was a
  protocol-floor issue, but most are internet background noise
  (HTTP-on-TLS-port, port-scan garbage, open-proxy `CONNECT`); only
  `unsupported protocol` / `version too low` are actual legacy peers. The
  OpenSSL `tls_version` field was renamed to `negotiated_version` (since
  `SSL_get_version()` returns the **server** object's version — equal to the
  peer's offer only when the ClientHello was parsed far enough, otherwise the
  server default, so a plaintext-HTTP probe is logged as `TLSv1.3`), and the
  same `negotiated_version` field was **added** to the mbedTLS line via
  `mbedtls_ssl_get_version()` (which honestly returns `"unknown"` pre-
  negotiation) so both backends log a symmetric field set. Log-text/field-name
  only; no behaviour change.
- **fix(ytls): raise the `ssl_verify_depth` default from 1 to 2.** OpenSSL
  counts the trust anchor in the chain depth, so the minimal verification
  path against any public CA is leaf(0) → intermediate(1) → root(2). With
  the old default of 1, a verifying TLS **client** (`ssl_verify_mode`
  `required`/`optional` with a CA) rejected every normal modern chain as
  `X509_V_ERR_CERT_CHAIN_TOO_LONG` at depth 2 — observed on `auth_bff`
  connecting to a Let's Encrypt-fronted Keycloak (`certificate verify
  failed` after "certificate chain too long"). 2 is the de-facto floor;
  cross-signed / extra-intermediate chains still need an explicit higher
  `ssl_verify_depth`. Only the computed default changed in `openssl.c`;
  `ytls.h` and `guide_tls.md` updated. The mbed-TLS backend has no depth
  knob and is unaffected.
- **refactor(ytls): drop the handshake "forensic transcript".** Both TLS
  backends captured every inbound handshake byte into a 16 KB per-socket
  buffer and dumped it (hex) on handshake failure. The dump was useless — in
  TLS 1.3 everything after ServerHello is ciphertext, and the cleartext
  records are better read with the `C_TCP` `traffic` trace or a pcap — while
  every connection paid for the allocation and a per-chunk memcpy. Removed
  `HANDSHAKE_TRANSCRIPT_MAX`, the `handshake_transcript` field,
  `capture_handshake_bytes()` and all decref sites from `openssl.c` /
  `mbedtls.c`. The default-on `gobj_log_warning` recording the rejection
  reason (error, peername, sockname, SNI, negotiated_version) is kept. The two
  `test_handshake_dump_{openssl,mbedtls}` tests were repurposed as
  `test_handshake_reject_*` (a bogus HTTP-on-TLS-port handshake is rejected
  cleanly: `error=-1`, no crash) — both backends pass.
- **fix(agent-sync): classify `sync-binaries`/`sync-configs` against every
  installed slot, not just the active primary.** `*list-binaries` /
  `*list-configs` report only the primary; with a snap active the primary
  can be an OLD version while the freshly built one is already installed as a
  non-primary slot, so every role was mis-classed `BUMP` and then fired a
  doomed `install-binary` / `create-config` (the agent rejects with "Node
  already exists"). The full set is now read from `*list-binaries-instances`
  / `*list-configs-instances` and used as the authoritative "is this version
  already installed?" check: a version installed but not primary is the new
  `INSTALLED` status (skipped, with a hint to promote via
  `yunetas upgrade-yunos`); `BUMP` now means "not installed and newer than
  the primary". `tools/README.md` tables updated.
- **fix(agent-sync): skip the kill/restart cycle when the rebuilt version
  isn't the one running.** The REBUILD path (`update-binary`) stopped and
  restarted the role if ANY instance was live, but `update-binary` overwrites
  only the slot whose version equals the uploaded binary's, and
  text-file-busy bites only when a LIVE process is mapped to that exact file.
  `deploy_update_with_restart` now reads `role_version` per instance from
  `*list-yunos` and only kills/restarts when an instance running the version
  being written is live (unknown `role_version` → treated as on-target,
  killed on the safe side).

7.6.6

- **build(js): extract `@yuneta/gobj-js` to its own repository as a git
  submodule (symmetric with gobj-ui).** gobj-js was the last in-tree JS
  framework package; it now lives at `github.com/artgins/gobj-js` (public)
  and is embedded as a git submodule at `kernel/js/gobj-js`, the same model
  as gobj-ui and `utils/python/tui_yunetas`. The new repo is a clean
  snapshot (history not preserved), single line on `main`, tag `7.6.5`
  tracking `YUNETA_VERSION`. **Clone with `--recurse-submodules`** (or
  `git submodule update --init`). The submodule sits at the original path,
  so local `file:` consumers (`wattyzer`, in-repo `yunos/js/gui_treedb`)
  resolve unchanged and npm consumers (`estadodelaire`, `hidraulia`) are
  unaffected. New publish flow: bump `package.json` in lockstep with
  `YUNETA_VERSION` and `npm publish` **in the standalone repo**, then bump
  the submodule pointer here. `.gitmodules` uses the HTTPS url (so
  `--recurse-submodules` works for every cloner) for all three submodules;
  the gobj-ui submodule's stale internal name (`lib-yui`) was also aligned
  to `gobj-ui` and its url switched SSH → HTTPS in the same pass.
- **chore(ext-libs): bump nginx 1.30.2 → 1.31.2 (v1.17)**. Fixes three
  CVEs: CVE-2026-42530 (use-after-free in `ngx_http_v3_module`),
  CVE-2026-42055 (buffer overflow in the HTTP/2 paths of
  `ngx_http_proxy_module` / `ngx_http_grpc_module`) and CVE-2026-48142
  (buffer overread in `ngx_http_charset_module`). Pin-only — nginx is a
  separate dynamically-linked binary (see `configure-libs.sh` v1.10), so
  no yuneta consumer / header / CMake change rides along. NOTE: `1.31.x`
  is the nginx *mainline* branch (odd minor), not the `1.30.x` stable
  line we were on — chosen because the fixes landed there. openresty
  (`1.29.2.5`) is a separate binary and is **not** covered by this bump;
  track upstream openresty for a release that picks up these patches.
  Each deployed project must rebuild its own nginx copy.
- **chore(ext-libs): bump openresty 1.29.2.5 → 1.31.1.1 (v1.18)**. Advances
  the openresty-bundled nginx core from `1.29.2` to `1.31.1` (released
  2026-05-29). Pin-only — openresty is a separate dynamically-linked binary
  (so its bundled OpenSSL 3.5.6 is irrelevant to our build). ⚠️ **CVE
  status:** nginx 1.31.1 does NOT cover the three CVEs fixed in nginx
  `1.31.2` (CVE-2026-42530 / 42055 / 48142, 2026-06-17); openresty 1.31.1.1
  was tagged *before* nginx 1.31.2, so the openresty binary — the one that
  actually fronts the SPAs — remains exposed until upstream ships a release
  based on ≥ nginx 1.31.2. The standalone nginx binary IS patched (v1.17).
  Each deployed project must rebuild its own openresty copy.
- **observability(prot): attribute protocol parse errors to the source IP
  (`peername`).** Server-side protocol gclasses logged malformed-input
  errors without the remote peer's address — `peername` is set on the bottom
  `C_TCP` (`SDF_VOLATIL`) and the upper layers never copied it into their own
  logs, so a bad-frame / bad-header event was not attributable to a device
  or attacker without cross-referencing the `C_TCP` `Connected` line by
  timestamp. The canonical read pattern (already in `c_websocket.c` /
  `c_prot_mqtt2.c`) is now applied in the cold error branch of each
  remote-data parse-error log: `c_prot_tcp4h.c` (head-too-long,
  protocol-error disconnect, protocol timeouts) and `ghttp_parser.c` (the
  invalid-UTF-8 header-value store error; the main "non-HTTP data received"
  violation already carried it). `c_prot_http_sr.c` / `c_channel.c` only
  emit registration / internal "no bottom" logs (not remote-attributable)
  and are left untouched; outbound clients (`c_prot_http_cl.c`) and the
  `c_prot_mqtt2.c` gap-fill are deferred. No FSM/schema/API change — logs
  gain a `peername` field only. See TODO.md "source-IP attribution".
- **security(glogger): escape invalid UTF-8 in log fields (logcenter parse
  DoS).** `_ul_str_escape()` copied every byte `0x7f-0xff` verbatim (via the
  `json_exceptions[]` table) without validating UTF-8. A corrupted device
  payload logged verbatim (e.g. an FS00802_4G sensor leaking modem AT
  commands + raw bytes into its MQTT JSON) leaked lone invalid bytes
  (`0x8a`, `0xc2`, ...) into the log record; the record was then no longer
  valid UTF-8, so the logcenter's `gbuf2json()` rejected and dropped it
  ("unable to decode byte 0x8a"), losing the log. Fixed at the root so no
  field from any emitter can produce an unparseable record: `json_exceptions[]`
  and the non-thread-safe static `exmap` are gone; a strict
  `utf8_valid_seq_len()` validator (rejects overlong encodings, surrogates,
  `> U+10FFFF`, never reads past the NUL terminator) now drives the escaper,
  which copies valid UTF-8 sequences verbatim (logs stay readable) and
  escapes invalid/control bytes as `\u00XX`. Worst-case output size is
  unchanged (<=6 bytes/char), so the caller's buffer sizing is untouched.
  Regression test `tests/c/glogger_utf8` registers a capture log handler and
  re-parses the emitted record with `anystring2json` (what the logcenter
  does), proving an invalid-UTF-8 payload now yields a valid JSON/UTF-8
  record while legitimate UTF-8 (`café`, `€`) is preserved verbatim.
- **security(mqtt): reject zero-length payload frames that crashed the
  broker (NULL-gbuf remote DoS).** A control packet whose MQTT "remaining
  length" was 0 left `frame_completed()` with a NULL payload gbuffer, which
  it then handed to a handler that dereferenced it
  (`gbuffer_leftbytes(NULL)`) → SIGSEGV. A single malformed packet from a
  remote client crashed the whole broker process (observed in
  `handle__subscribe`). Fixed in both protocol gclasses (`C_PROT_MQTT` and
  `C_PROT_MQTT2`) with two layers: at header validation, `frame_length == 0`
  is now rejected for every command carrying a mandatory payload, before a
  NULL gbuf can reach a handler (MQTT5 still permits a zero-length
  DISCONNECT/AUTH, whose handlers already tolerate it; PINGREQ/PINGRESP keep
  their must-be-zero check); and the read primitives
  (`mqtt_read_uint16/uint32/bytes/byte/varint`) now treat a NULL gbuf as a
  malformed packet before touching `gbuffer_leftbytes`. Regression test
  `tests/c/c_mqtt/test_mqtt_malformed` injects a malformed in-session
  SUBSCRIBE (`0x82 0x00`) at the transport: it crashes with the exact
  production backtrace without the fix and passes with it.
- **build(js): the JS UI library was extracted to its own repository and
  renamed `@yuneta/lib-yui` → `@yuneta/gobj-ui`.** It now lives at
  `github.com/artgins/gobj-ui.js` and is embedded as a git submodule at
  `kernel/js/gobj-ui` (clone with `--recurse-submodules`), the same model as
  `utils/python/tui_yunetas`. The repo carries two maintained lines, each
  consumed a different way:
    - **`main`/v2** (npm dist-tag `latest`, tag `2.0.0`+, `src/` layout) —
      active development: the declarative shell
      (`C_YUI_SHELL/NAV/PAGER/WIZARD`) on top of the legacy stack. **The
      yunetas submodule now tracks this line**, and `wattyzer` consumes that
      checkout locally via a `file:` dependency (importing
      `@yuneta/gobj-ui/src/*` by package specifier).
    - **`v1`** (npm dist-tag `legacy`, tag `1.0.1`, `src/` layout) — the
      frozen legacy GClass GUI stack. Consumed from the **npm registry** as
      `@yuneta/gobj-ui@^1.0.1` by `estadodelaire`, `hidraulia` and the
      in-repo `yunos/js/gui_treedb` (NOT a local `file:` — the local
      submodule is v2 now).
  The old `lib-yui` name collided with Yahoo's YUI on npm; only the package
  identity changed — internal naming (`C_YUI_*`, `c_yui_*`, `yui_*`, `yi-*`)
  is unchanged. Both lines use the `src/` layout (v2 was restructured to
  match v1). Published to npm as `@yuneta/gobj-ui` (`latest`=2.0.0,
  `legacy`=1.0.1); the abandoned `@yuneta/lib-yui` was unpublished.
- **build(js): `@yuneta/gobj-js` is versioned to `YUNETA_VERSION` and
  published to npm.** `kernel/js/gobj-js/package.json` now tracks the SDK
  version (currently `7.6.5`); bump it in lockstep and `npm publish`.
  `estadodelaire`/`hidraulia` consume it from the registry
  (`@yuneta/gobj-js@^7.6.5`); `wattyzer` and `yunos/js/gui_treedb` keep a
  local `file:` dependency on `kernel/js/gobj-js`.

7.6.5

- **security(libjwt): re-review against upstream v3.4.0 and backport the
  reachable hardenings.** v3.4.0 is a large feature release (full JWE, the
  `crit` header, `jti` callbacks, PEM→JWK public API), almost none of which
  touches Yuneta's compiled subset. After filtering to the JWS verify/parse
  path, three items were backported into the vendored tree:
    - **`18133e4` (L17): reject duplicate JSON members on the token parse**
      (`jwt-verify.c`). The inbound header/payload is now parsed with
      `JSON_REJECT_DUPLICATES` (RFC 8725 §2.4), so a peer that selects a
      different occurrence of a duplicated claim/header cannot be made to
      disagree with us.
    - **`d180cc7`: enforce strict base64url on decode** (`jwt.c`). The
      decoder accepted the standard-base64 `+`/`/` and silently truncated
      on an embedded `=`; it now rejects anything outside `[A-Za-z0-9_-]`.
      Reachable on every token segment and JWK member decode.
    - **`fe8840a`: enforce the RFC 7515 §4.1.11 `crit` (Critical) header**
      (`jwt-verify.c` + both checker entry points). The parser previously
      ignored `crit`; since this copy understands no extension headers, any
      token carrying a well-formed `crit` is now rejected (checker side
      only — the builder side is not ported, C_AUTHZ does not sign).
  The batch's only CVE-class bug (`5fada81`, mbedTLS RSA short-signature
  OOB read) is not present here: the vendored mbedTLS backend is a v4.0/PSA
  rewrite using the length-aware `mbedtls_pk_verify_ext`, immune by
  construction. Regression coverage added to `test_jwt_alg_confusion`
  (`crit` rejection on both entry points; positive controls still verify).
  Full classification in `kernel/c/libjwt/README.md`.

7.6.4

- **fix(tr_msg2db): stop `msg2db_open_db` logging spurious schema errors
  when reopening with `jn_schema=NULL`.** The persistent reopen path (no
  schema dict passed — the schema is loaded from
  `<db>.msg2db_schema.json`) read the name and `schema_version` straight off
  the NULL `jn_schema`, so every open via `msg2db_list` (and any non-master
  reopen) emitted three red errors — `kw must be list or dict` /
  `path NOT FOUND` for `id`, and the same for `schema_version` — before the
  function then correctly loaded the schema from file. Now mirrors
  `treedb_open_db`: the name comes from the passed `msg2db_name_` when
  `jn_schema` is NULL (and is read with a non-`KW_REQUIRED` flag otherwise),
  and `schema_version` is guarded with `jn_schema? kw_get_int(...) : 0`. The
  resolved name and version are unchanged for the one non-NULL-schema caller
  (`c_mqtt_broker`, whose passed name already equals `jn_schema["id"]`);
  only the noise is gone.
- **refactor(msg2db_list): modernize the CLI to the `treedb_list` style.**
  The tool still carried its V6-era flags — most visibly `--path` / `-a`
  as the only way to point it at a store. It now takes the store as a
  **positional `PATH` argument** (the `-a` flag is gone) and shares the
  `treedb_list` ergonomics:
    - `resolve_msg2db_path()` deduces the tranger root, `--database` and
      `--topic` from `PATH` (a tranger root, a `<db>.msg2db_schema.json`,
      or a topic directory), auto-discovering the single schema when
      `--database` is omitted and listing the candidates when it is
      ambiguous or missing.
    - new presentation flags `--mode form|table` / `-m` and
      `--fields` / `-f` (field selection implies table mode), rendering
      columns from the topic's `cols` `fillspace` just like `treedb_list`.
    - new `--dry-run` / `-n` prints the resolved path / database / topic
      plus the ids and filter JSON, then exits without listing.
    - `PATH` is normalized (trailing slashes stripped, `./` prefixed for
      bare relative names); the per-topic header is highlighted and the
      recursive walk keeps its per-database record count.
  `--follow` is intentionally NOT ported: `tr_msg2db` exposes no
  change-callback equivalent to `treedb_set_callback`. The mega-header
  `<yunetas.h>` include was replaced by the specific kernel headers.
- **chore(ytls): de-duplicate and de-noise the rejected-handshake logs.** A
  single rejected connection (e.g. a non-TLS/HTTP client hitting the TLS
  port) emitted overlapping lines across the ytls and transport layers.
  Now:
    - "TLS handshake rejected" is INFO (was WARNING) in both backends — a
      sub-floor/legacy/non-TLS peer is routine, not actionable; it was
      inflating "Global Warnings".
    - that default-on line is self-contained: it now carries
      `peername`/`sockname`, handed to ytls by the transport through a new
      optional `ytls_set_peer_name()` (per-`sskt`, both backends), so the
      offending peer is identifiable even with `connections` trace off.
      (ytls does NOT reinterpret `user_data` as a gobj — unit tests pass a
      non-gobj `user_data`; callers that skip the setter just log `""`.)
    - the transport's `ytls_on_handshake_done_callback` no longer logs the
      FAILS case (it duplicated the ytls line); it keeps "TLS Handshake OK".
    - `set_trace` no longer logs the per-connection `trace:0` disable (pure
      noise on every accept); it logs only when enabling. Both backends.
- **feat(libjwt): trace the claims JSON on a failed-claims verification.**
  `__verify_config_post` now calls `gobj_trace_json` with `jwt->claims` when
  `__verify_claims` reports one or more failed claims, so the offending
  token's `iss`/`aud`/`exp`/`nbf`/… values are visible at the point of
  rejection. Emits unconditionally on the failure path (LOG_DEBUG). libjwt
  now back-references `gobj_trace_json`: real yunos already pull `glogger.o`,
  but the standalone libjwt unit test pulls nothing from it, so its link line
  repeats `libyunetas-gobj.a` after `JWT_LIBS` to resolve the reference.
- **fix(utils): TLS client utilities could not connect over `wss://` /
  `https://`.** The verify-by-default change made `build_ssl_ctx` refuse any
  TLS client whose `crypto` config lacks server-certificate validation, but
  the CLI utilities were never ported: they passed an empty `crypto` to
  their sockets, so every remote TLS connection (e.g. `ycommand` against the
  controlcenter, including its OIDC `task-authenticate` to the issuer) failed
  with *"TLS client refused: no server-certificate validation"*. All
  utilities now pass `crypto: {ssl_use_system_ca: true}` to their C_TCP (and
  to `C_TASK_AUTHENTICATE` where present): `ycommand`, `ystats`, `ybatch`,
  `ytests`, `ycli`, `mqtt_tui`, `emu_device`. `ycommand` additionally gains
  `--ssl-use-system-ca` (default on), `--ssl-trusted-certificate` (private
  CA) and `--ssl-allow-insecure-client` (MITM bypass) for non-public-CA
  endpoints. Plain `ws://` is unaffected (C_TCP ignores `crypto` without TLS).
- **feat(ytls): log `ssl_server_name` in TLS diagnostics; drop dead
  fields.** Every post-init `gobj_log_*` in the OpenSSL and mbedTLS backends
  now carries `ssl_server_name`, so handshake/verify/read/write errors show
  which SNI/server name the context was for. Also removed the never-used
  `rx_bf[16*1024]` field from `sskt_t` in both backends (~16 KB per live TLS
  connection) and the unused `error` field from the mbedTLS `sskt_t`.
- **fix(tui_yunetas 0.10.1): quieter `upgrade-yunos` output.** The two
  `find-new-yunos` steps no longer dump ycommand's raw stdout — the preview
  prints once (formatted) and `create=1` shows a one-line `Created N new
  yuno row(s).` summary. The post-`sync-binaries` install-binary reminder
  now leads with `yunetas upgrade-yunos` (raw ycommand sequence kept as the
  manual equivalent).
- **feat(tui_yunetas 0.10.0): agent-aware deploy.** `sync-configs` without
  `--host` now matches each registered project's `yunos/batches/<host>/`
  directories against the realm_ids the local agent manages
  (`*list-realms`) and syncs every match — a node running several realms
  deploys all the relevant ones in one pass, since a batches dir is named
  after its realm_id (the deploy FQDN). `--host` still targets one dir; an
  unreachable agent falls back to the legacy single-hostname guess; new
  `--url`/`-u`. New `upgrade-yunos` command bundles the version-bump
  promotion flow: optional rollback snapshot (idempotent by name,
  `pre-upgrade-<YYYYMMDD>`, `--no-snap`) -> `find-new-yunos` preview +
  confirm (`--yes`) -> `find-new-yunos create=1` -> `deactivate-snap`
  (restart_nodes: SIGKILL + treedb reload, newest release wins).
  `--dry-run` prints the agent commands without running them.
- **fix(tools): a resumed deploy is now idempotent instead of failing.**
  When a prior run installed the binaries / configs and registered the new
  yuno rows but never promoted them (`deactivate-snap` not reached),
  re-running the deploy hit the agent's "... already exists" answers.
  `sync_binaries.py` / `sync_configs.py` now report such an
  `install-binary` / `create-config` as `ALREADY PRESENT` (idempotent) and
  count it as ok, not a red `FAILED`. The matching `upgrade-yunos`
  fall-through (don't abort when `find-new-yunos create=1` only hits
  already-existing rows) ships in the tui_yunetas CLI 0.11.1. A genuine
  (non-idempotent) error still fails closed.

7.6.3

- **feat(treedb): immutable (non-deletable) topics and records.** A record
  can be marked immutable (md2 system_flag bit `sf_immutable_record`,
  surfaced as `__md_treedb__`immutable`) and a topic non-deletable
  (`system_topic` in `topic_var.json`) — the protection is METADATA, not a
  data column, so it needs no user-schema change and no `topic_version`
  bump. `treedb_delete_node` / `treedb_delete_instance` /
  `treedb_delete_topic` refuse it and `force` does NOT override; the record
  bit is inherited across updates and survives reload. New
  `treedb_set_node_immutable()` and a `system_topic` param on
  `treedb_create_topic`; the `__system__` treedb structural topics and per-
  treedb `__snaps__`/`__graphs__` are marked system. `c_authz` `mt_start`
  runs a master-only idempotent ensure-loop that stamps the Authz seed
  (`root` role / `yuneta` user) immutable on every start — deployed stores
  protected on next restart, no schema change, no wipe. Out of scope on
  purpose: `delete-treedb` / whole-store wipe. Test
  `tests/c/tr_treedb_immutable`; design in
  `kernel/c/timeranger2/DESIGN-immutable-topics-records.md`; docs in
  `YUNO_TREEDB.md` §3.10 + `YUNO_AUTH.md` §4.2.
- **fix(ytls): portable system-CA trust (`ssl_use_system_ca`) for static
  binaries, both backends.** A fully-static binary doesn't inherit the host
  OPENSSLDIR / `SSL_CERT_FILE`, so OpenSSL's `set_default_verify_paths()`
  loaded an EMPTY store and a valid public cert failed to verify. New
  `ytls_get_system_ca_bundle()` probes the well-known CA bundle FILES across
  distros (Debian/Ubuntu, RHEL/Rocky/Alma/Fedora, SUSE, Alpine — the
  hashed-dir CApath is not portable); OpenSSL loads it via
  `load_verify_locations`, and mbedTLS (no system store of its own) now
  parses it too instead of refusing the client. `C_PROT_HTTP_CL` gains a
  `crypto` attr (default verify-by-default) forwarded to its bottom C_TCP,
  and emailsender's `c_smtp_session` the same — so HTTPS polls (e.g. ESIOS)
  and SMTPS verify out of the box. This unbroke auth_bff's IdP TLS and
  stopped a ~1.2 MB/s "TLS handshake FAILS" log flood (-> ~0.6 KB/s).
- **feat(c_tcp): opt-in exponential reconnect backoff.** New
  `timeout_between_connections_max`: when > `timeout_between_connections`,
  the reconnect delay backs off from base up to the cap, resetting to base
  once a connection is established (for a TLS client, only on a successful
  handshake). A peer that keeps failing — e.g. an IdP whose cert won't
  verify — no longer hammers at the base cadence. `auth_bff` uses it (100 ms
  first retry, 30 s cap).
- **fix(logcenter): `search` / `tail` no longer crash on a truncated log,
  and read fast.** `extrae_json` brace-counting `abort()`ed the whole daemon
  when a truncated UDP log entry left a `{` with no `}` (it grew past the
  max block) — taking logcenter down on a read-only command. Records are now
  split on the `<PRIORITY>: ` line prefix `rotatory_write()` already writes
  (robust to truncation and multi-line JSON; a `\0` on-disk terminator was
  rejected as it would make the log binary for grep/less/vim). Reads use
  64KB blocks + `memchr` instead of `fgetc()` per byte, and `tail` seeks to
  the last window: on a 518MB log, tail 72s -> 2s, search 70s+/crash -> 2-7s.
- **refactor(tui_yunetas 0.9.1): project registry moved to
  `~/.yuneta/projects.json`.** The external-project registry was written
  inside the source tree (`$YUNETAS_BASE/.projects.json`, gitignored). But
  which projects to build alongside the SDK is runtime/usage state, not a
  property of any checkout — it does not belong in the tree at all. It now
  lives in the user's home (`~/.yuneta/projects.json`), independent of
  `YUNETAS_BASE`. A one-time soft migration moves an existing legacy file
  on the next CLI run; no manual step. The `.gitignore` / `.hgignore`
  entries for the old path are kept as a safety net while pipx CLIs on
  other nodes still write the legacy location.
- **fix(treedb): refused `treedb_delete_instance()` no longer drops a
  borrowed node ref.** The snapshot-tag guard's refusal path decref'd the
  node even though callers (`mt_delete_node`, tests) pass the index's
  borrowed pointer — a refused per-instance delete of a snap-tagged node
  left the index slot one ref short (latent use-after-free / double-free).
  The refusal path now leaves the node untouched; the ref is consumed only
  on success, where `delete_secondary_node()` extracts it from the index,
  same convention as `treedb_delete_node()`.
- **fix(performance): `perf_yev_ping_pong2` no longer reports a first-run
  `tranger2_startup` error.** The startup phase expected NO logs, but on a
  node where `~/tests_yuneta/` had never been created (e.g. a fresh VM)
  `tranger2_startup` emits the one-time INFO "Creating
  `__timeranger2__.json`" — flagged as unexpected by the strict
  expected-results FIFO. Simply adding the log to the expected list would
  break the opposite case (database already created by a previous test or
  run). Fix follows the established pattern
  (`test_tr_treedb_update_instance.c`): wipe the database with `rmrdir`
  before `tranger2_startup` so the creation INFO is always emitted, and
  expect it. Verified with back-to-back runs (fresh and leftover store).
- **chore(packages): drop `stress_*` lab binaries from the .deb/.rpm
  payload.** The CI builds the whole tree and the packagers copied
  `outputs/` wholesale, so the stress load-generators (`stress_auth_bff`,
  `stress_listen`, …) shipped on every production node. They are now
  stripped at staging time. `perf_*` benchmarks stay on purpose: fully
  static, they are handy to measure a target machine right after install
  (validated on the 7.6.2 Ubuntu VM).
- **fix(packages): pipx CLIs install for the operator, not for root.**
  `install-yuneta-dev-deps.sh` (deb/rpm) runs as root, so `pipx install
  kconfiglib yunetas` landed in `/root/.local/bin` — invisible to the
  `yuneta` operator account (verified on a clean Ubuntu VM: `yunetas:
  command not found` after a full install). The script now installs the
  pipx apps for the `yuneta` user when it exists (falling back to
  `$SUDO_USER`, then root), via `runuser -l` so pipx resolves the right
  `$HOME`. The staged `profile.d/yuneta.sh` already has
  `/home/yuneta/.local/bin` on PATH, so the CLIs work on next login with
  no `pipx ensurepath` step.

7.6.2

- **feat(tui_yunetas 0.9.0): external projects integrated into the
  `yunetas` CLI.** New `register-project` / `unregister-project` /
  `list-projects` commands keep a machine-local registry in
  `$YUNETAS_BASE/.projects.json` (gitignored); `init` / `build` / `clean`
  now also process each registered project's `yunos/` after the SDK
  (select with positional project names, or `--sdk-only` to skip them).
  New `sync-binaries` / `sync-configs` subcommands wrap
  `tools/agent/sync_*.py`, forwarding arguments; `sync-configs` walks the
  registered projects' `yunos/batches/<host>/` directories (`--host`
  selector with hostname auto-match), closing the discovery gap that
  forced a manual `cd` into each batches dir.
- **fix(env): `yunetas-env.sh` exported a stale artefacts layout.**
  `YUNETAS_OUTPUTS` / `YUNETAS_YUNOS` pointed at the PARENT directory of
  the repo (`$(dirname $YUNETAS_BASE)/outputs`), a layout nothing else
  uses: `project.cmake`, the CLI and the `.deb`/`.rpm` payload all agree
  on `$YUNETAS_BASE/outputs[_ext]`. Both variables now follow that rule,
  a new `YUNETAS_OUTPUTS_EXT` is exported, and `deactivate_yunetas`
  unsets all of them. The legacy `$HOME/yunetaprojects` branch was
  dropped from the profile script staged by the `.deb`/`.rpm` packagers,
  and the docs (`CLAUDE.md`, `installation.md`) were aligned.
- **feat(packages): one outputs/ path on every node.** The `.deb`/`.rpm`
  used to stage the SDK payload directly under `/yuneta/development/`
  (`outputs/`, `outputs_ext/`, `tools/`, `.config`), so runtime-only nodes
  had a different `YUNETAS_BASE` (and outputs path) than source checkouts.
  Both packagers now stage the same payload as a sparse SDK under
  `/yuneta/development/yunetas/` — the SAME base path as a full source
  tree — so `outputs/` is `/yuneta/development/yunetas/outputs` everywhere
  and the two-branch layout conditional in the staged `profile.d/yuneta.sh`
  collapses to a single unconditional block. The `yunetas` CLI handles
  these runtime-only trees (no `YUNETA_VERSION`): `init <project>` /
  `build <project>` work against the shipped headers, and a plain `init`
  refuses to wipe the shipped `outputs/`. Legacy `/yuneta/development`
  fallbacks remain in the resolution chains for nodes installed with older
  packages; on upgrade dpkg/rpm relocate the payload automatically, but
  already-configured project `build/` dirs cache the old paths — re-run
  `cmake` (or `yunetas init <project>`) after upgrading.
- **fix(ycli,ycommand): assemble local-config paths via `build_path`.**
  The `save_local_json/string/base64` helpers built
  `$HOME/.yuneta/configs/<name>` with `snprintf` into a NAME_MAX buffer
  while the sanitized name was also NAME_MAX, so GCC emitted
  `-Wformat-truncation`. `build_path()` (the hard-rule path helper) does
  the assembly and logs LOG_CRIT on real overflow instead of silently
  truncating.
- Note: the 7.6.1 tag already shipped two undocumented renames — the
  legacy `cli` gobj/service is now `ycli`, and its global config key
  `Cli.shortkeys` is now `ycli.shortkeys`.

7.6.1

- **fix(authz,root-linux): a root superuser reaches any service of the
  node.** The 7.6.0 per-message `dst_service` gate (`is_service_authorized`
  in `c_ievent_srv.c`) authorized only the channel's `authorized_services`
  — the *keys* of `services_roles`. But the local trusted `yuneta` user
  authenticated through the `yuneta_by_local_ip` shortcut in `c_authz.c`,
  which hardcoded an EMPTY role set (`{"agent":[]}`, the old
  `// TODO not need role?`), so its real `root` role (`service="*"`,
  `realm_id="*"`) never reached the channel. Result: the local control
  plane (`ycli` warming its command cache with `list-gobj-commands` to
  `dst_service="__yuno__"`, `ycommand`, …) was REJECTED at `__yuno__` and
  any sibling service — root could not reach the yuno root. Now the local
  `yuneta` goes through the SAME `get_user_roles()` filter as any user (no
  hardcode); `get_user_roles()` flags the channel `superuser` when the user
  holds an effective `service="*"` role (computed from the wildcard, not the
  literal role name), propagated in the authenticate response and stored as
  the `is_superuser` channel attr. `is_service_authorized()` returns TRUE for
  a superuser: any realm/service/permission by definition, so it is not a
  cross-service escalation. Scoped roles (`developer`, `sysop`, …) stay
  limited to their granted services — the 7.6.0 cross-service protection is
  intact for them. What a command may DO is still governed by the
  default-off per-command authz, orthogonal to this routing gate.
- **fix(root-linux): the ievent server never leaves a channel zombie when it
  refuses a message.** `ac_on_message` rejected an unrouted `dst_service`
  (unauthorized or not found) with a bare `return -1`, which both skipped
  any answer AND left the socket read un-rearmed (`c_tcp` only re-arms on a
  `0` return from the `EV_RX_DATA` publish chain): the channel stayed
  connected but deaf and the peer waited forever. New `reject_unrouted_iev()`
  never returns `-1` silently: `command` / `stats` (which have a natural
  answer channel) get a negative `EV_MT_*_ANSWER` with the reason and the
  read re-arms (`return 0`); `subscribe` / `unsubscribe` / `inject` (no
  answer) `drop()` the channel for a clean disconnect. Applied to both the
  unauthorized-service and the service-not-found paths.
- **build(cmake): link the kernel and external static libraries by full
  path so consumers auto-relink.** `tools/cmake/project.cmake` listed the
  `.a` files as bare names resolved via `link_directories()` `-L`, which
  CMake treats as plain `-l` flags with NO file dependency: after editing a
  kernel source and rebuilding its `.a`, dependent yunos were NOT relinked
  (`make` reported `Built target` with the stale binary; the workaround was
  to delete the binary first). Each archive is now given by full path
  (`${LIB_DEST_DIR}/...` for yuneta's own, `${EXT_LIB_DIR}/...` for
  `outputs_ext/lib` third-party), so CMake tracks it as a link dependency
  and `make` / `yunetas build` relinks automatically when a lib changes.
  System libs (`pthread`, `dl`) stay bare. Verified: a full clean rebuild is
  green and touching `libyunetas-core-linux.a` relinks the agent with a
  plain `make`.

7.6.0

- **security(root-linux): authorize per-message dst_service against the
  authenticated service set on the ievent server.** `ac_on_message`
  (subscribe / unsubscribe / inject) and `ac_mt_stats` resolved the
  `dst_service` / `service` from the attacker-controlled routing stack and
  dispatched against any registered service. A peer authenticated for
  service A could subscribe to events of, inject into, or read/reset the
  stats of another service B by naming it. Both paths now check the resolved
  service against the set this channel is authorized to reach, captured at
  identity-card time from the `services_roles` returned by
  `gobj_authenticate()` (the keys: the primary `dst_service` plus any
  `required_services` the user holds real treedb roles in — see `append_role`
  in `c_authz.c`; the no-treedb path yields just the primary service). This
  implements the long-standing `available_services` TODO: a single
  authentication legitimately grants several services (the multi-service GUI
  frontends authenticate against `db_history_wz` and reach
  `treedb_wattyzer` / `treedb_authzs` / …), while a service outside the
  granted set is refused. The authorized set derives from real roles, not
  the client-supplied `required_services`, so it cannot be spoofed.
  Validated end-to-end: the wattyzer SPA against a patched `db_history_wz`
  logs in, opens its ievent channel, and loads multi-service data with zero
  gate denials. (`ac_mt_command` cross-service reach stays gated by the
  default-off per-command authz — threat-model T7, an accepted posture.)
- **security(root-linux): resolve the `C_PTY` `process` attr against a
  trusted-dir allowlist, never `$PATH`.** The remote-settable `process`
  value (set by the authz-gated `open-console` command) reached `execvp()`,
  which consults the inherited `$PATH` — a planted PATH entry could hijack a
  bare name. New `resolve_process_path()` accepts an absolute path only if
  executable, resolves a bare name against a fixed list of system dirs
  (`/bin`, `/usr/bin`, `/sbin`, `/usr/sbin`, `/usr/local/bin`), rejects
  relative-with-slash, and fails closed (empty argv[0] → no exec).
  `execvp` → `execv`.
- **security(ycommand/ycli): sanitize peer-supplied config record names
  before they become local filenames.** A malicious peer's command-answer
  record `name`/`id` (`view-config` / `read-json` / `read-file` /
  `edit-config`) flowed unsanitized into the `"<editor> <path>"` string that
  `pty_sync_spawn()` hands to `/bin/sh -c` — RCE on the operator host (e.g.
  `"x; rm -rf ~ #"`). New `sanitize_config_name()` folds everything outside
  `[A-Za-z0-9._-]` to `_`, forbids a leading dot, and collapses path
  separators to a single inert basename, applied in all `save_local_*`
  builders of both tools.
- **harden(ytls/mbedtls): opted-in insecure client is never silent —
  observability parity with openssl.** An accepted
  `ssl_allow_insecure_client=true` client now logs the same *"TLS client
  WITHOUT server-certificate validation (MITM surface)"* warning as the
  openssl backend, and runs the handshake under `VERIFY_OPTIONAL` instead of
  `NONE` so mbedTLS still computes the verify result and the tolerated
  failure is surfaced at handshake end (openssl records it natively even
  under `VERIFY_NONE`; mbedTLS skips verification entirely under `NONE`).
  The accept decision is unchanged (`OPTIONAL` never aborts; `CA_CHAIN_REQUIRED`
  fires only under `REQUIRED`, and the missing-hostname hard error is
  `REQUIRED`-only too). The *"did NOT verify"* warning guard now keys off
  the effective authmode instead of `has_ca_cert` (under `NONE`,
  `verify_result` holds `BADCERT_SKIP_VERIFY` and must not false-fire).
  Fixes the 11 TLS ctest failures under an mbedTLS-only `.config` — the
  test expectations had encoded openssl-only emissions. Verified 112/112
  with each backend.
- **security(gobj-c): reject `gbuffer_create()` `data_size == SIZE_MAX`.**
  `GBMEM_MALLOC(data_size+1)` wrapped to `malloc(0)` — a non-NULL ~0-byte
  buffer that slips past the `__max_block__` guard while `gbuf->data_size`
  stays `SIZE_MAX`, defeating every later bounds check (`gbuffer_freebytes`
  / append `memmove`). Now rejected at creation. Regression test
  `test_gbuffer_guards.c::test_wrap_guard`.
- **security(gobj-c): fix OOB heap over-read in `kwid.c` `collapse()`.** The
  in-tree `gbmem_strndup(str, size)` is a raw `memmove(s, str, size)` (not a
  real `strndup`), so building the path with
  `gbmem_strndup(path, strlen(path)+strlen(key)+2)` read past `path` by
  `strlen(key)+1` bytes and left the buffer unterminated before the
  `strcat`s. Replaced with `GBMEM_MALLOC` + `strcpy` at the exact size.
- **security(libjwt): pin the exact JWT algorithm, not just the key family.**
  On the common pinned path (`config->alg == config->key->alg`, both set) the
  alg-vs-alg chain in `__verify_config_post` never compared the token alg, and
  the `kty` backstop is only family-granular (`jwt_alg_required_kty` maps every
  RS/PS alg to RSA) — so e.g. an **RS512 token verified against an RS256-pinned
  key**. Added an exact-alg check against whichever alg is pinned. Purely
  additive: no legitimate token is newly rejected. Complements the
  GHSA-q843-6q5f-w55g cross-family fix already in 7.x.
- **security(yev_loop): fix use-after-free when a callback destroys its own
  event.** A callback calling `yev_destroy_event()` on its own event freed it
  synchronously, then `callback_cqe`'s re-arm block and dispatch tail
  dereferenced freed memory (last in-flight CQE). New `in_dispatch` flag
  defers the free to the dispatch tail; the re-arm blocks now also test
  `!destroy_requested` so a dying event is never re-armed.
- **security(root-linux): guard NULL header value in `ghttp_parser.c`
  `on_header_value()`.** A previous chunk's `json_string()` failing on invalid
  UTF-8 leaves no value under `cur_key`; the next continuation chunk then hit
  `strlen(NULL)` (attacker bytes + TCP segmentation). Guarded before `strlen`,
  restart the accumulator from the current chunk, and log the store failure
  instead of silently truncating.
- **security(root-linux): fix re-entrant-free UAF and TLS-error teardown in
  `c_tcp.c` `set_secure_connected()`.** The post-handshake `ytls_flush()` can
  re-enter and destroy the connection (an `EV_RX_DATA` subscriber) or report a
  TLS error. The return was ignored, so `start_pending_writes()` then ran on a
  freed gobj. Now: `-2222` (re-entrant free) bails without touching gobj/priv;
  `-1111` (TLS error, gobj alive) calls `try_to_stop_yevents()` and returns —
  matching the decrypt-path discipline. Requires the ytls change below.
- **security(ytls): propagate `flush_clear_data()` errors out of openssl
  `flush()`.** It swallowed the negative return (including the `-2222`
  re-entrant-free sentinel); now returns it so `c_tcp` can act on it.
- **security(emailsender): reject CR/LF/control chars in `attachment` and
  `inline_file_id`.** Both reach MIME headers raw via `append_attachment_part()`
  (`Content-Type name=` / `Content-Disposition filename=` / `Content-ID`), and
  `EV_SEND_EMAIL` is public — so they were an SMTP/MIME header-injection vector.
  Added to the single-line control-char rejection set alongside the envelope
  and display fields.
- **security(gobj-c): pre-auth NULL deref in `gbuffer_deserialize()`.** A
  malformed base64 `data` field makes `gbuffer_base64_to_binary()` return
  NULL, fed straight into the unguarded `gbuffer_setmark()` inline — a daemon
  crash reachable pre-auth via the ievent server (`ac_on_message` →
  `kw_deserialize`). Added the NULL check after decode plus a central guard
  on the `gbuffer_setmark`/`getmark` inlines. Same change: `kwid_find_record_in_list()`
  returned `0` (a valid index) on not-found instead of `-1` (silent
  wrong-record match in the list comparator), and the flatten/unflatten
  helpers in `kwid.c` moved off raw libc `malloc`/`strdup`/`free` onto the
  mandated `gbmem_*`. Regression tests in `test_kw1.c`.
- **security(libjwt): make the JWT verify contract fail closed.**
  `jwt_checker_verify2()` handed back the parsed claims regardless of outcome
  — the verdict lived only in `jwt_checker_error()`, so a caller trusting the
  non-NULL return would accept a forged / expired / alg-confused / unsigned
  token. It now returns NULL on any verification failure (the return value
  carries the verdict) and `jwt_verify_complete()` aborts early on
  `__verify_config_post` failure. Also fixed a wrong-free on the
  `jwk_process_one()` OOM path (freed the borrowed `jwk`, not the owned
  `item`). Regression: `test_jwt_alg_confusion.c::test_verify2_fail_closed`.
- **security(timeranger2): validate pkey/id against path traversal.** A
  string primary key or treedb/msg2db node id becomes a `keys/<key>/`
  directory component, so an attacker-influenced value containing `/` or
  beginning with `.` could escape the topic's `keys/` dir on append
  (mkrdir/newfile), `tranger2_delete_key` (rmrdir), the disk mirror, and
  `tranger2_delete_instance`. Rejected at every sink in `tranger2_append_record`
  / `tranger2_delete_key` / `tranger2_delete_instance` / `treedb_create_node`
  / `msg2db_append_message`. Regression:
  `tests/c/timeranger2/test_pkey_path_traversal.c`.
- **security(yev_loop): connect() the static DNS resolver socket.** The
  `CONFIG_FULLY_STATIC` resolver read UDP replies from any source, so
  authenticity rested only on the 16-bit transaction id — an off-path
  attacker could forge an A/AAAA answer and redirect a yuno's outbound
  connection. `dns_query()` now `connect()`s the UDP socket to the chosen
  nameserver (both IPv4/IPv6 branches) so the kernel drops datagrams from any
  other source. Regression:
  `tests/c/yev_loop/static_resolv/test_static_resolv_spoof.c`.
- **security(ytls): verify-by-default for TLS clients (BREAKING).** A TLS
  *client* that would run `VERIFY_NONE` (no CA / effective authmode NONE) is
  now refused at ctx/state build time in both backends instead of merely
  logging a warning — closing a live MITM hole (the auth_bff → Keycloak
  outbound client ran unverified). Opt back in per gate with the new
  `ssl_allow_insecure_client=true` (default false) for self-signed / PSK /
  IoT bring-up. The mbedTLS gate keys off the effective authmode for openssl
  parity. The `C_AUTH_BFF` `crypto` and `c_authz` `kc_crypto` defaults
  flipped to a verifying posture (`ssl_use_system_ca` + `ssl_verify_mode=required`).
  **Rollout:** any TLS-client deployment relying on silent `VERIFY_NONE` must
  add a CA (or `ssl_allow_insecure_client=true`) before it will connect.
- **fix(root-linux): check `ytls_init()` NULL in `c_tcp.c` connect path.**
  Follow-up to verify-by-default: the refusal is a soft failure (`ytls_init`
  returns NULL, yuno stays up), but `ac_connect` never checked it and the
  established connection then called `ytls_new_secure_filter(NULL, ...)` —
  SEGFAULT (caught by `perf_c_tcps` test4/test5). Now the connect aborts
  cleanly via `try_to_stop_yevents()`. Also added the missed
  `ssl_allow_insecure_client=true` to the `perf_c_tcps` test4/test5 client
  crypto blocks (the `tests/c/c_tcps*` sweep skipped `performance/`).
- **harden(yuno_agent/yuno_agent22): pin the controlcenter client to the
  canonical agent certificate.** The outbound controlcenter client
  (`tcps://<arch>.<owner>.<output_url>`, active when `node_owner != "none"`)
  ran with no CA and would be refused under verify-by-default. It now pins
  `/yuneta/agent/certs/yuneta_agent.crt` — the self-signed canonical cert
  the controlcenter actually serves (fingerprint-verified), already present
  on every node (the agent's own wss server uses the same file) — with
  `ssl_server_name=yuneta_agent.yuneta.io` (the cert has no SAN, hostname
  check falls back to CN). Supporting change in `c_tcp.c`: a config-supplied
  `ssl_server_name` now wins over the url-derived host, enabling pinning
  where the pinned cert's name differs from the dialed host. **Caveats:**
  the pin authenticates "a yuneta install" (the key ships on every node),
  not the controlcenter specifically — still a real upgrade over
  no-verification; and on a node missing the cert file the first
  controlcenter dial exits the agent (`ssl_trusted_certificate` load is
  fatal on first init) — verify the file exists before deploying with a
  non-none owner.

7.5.12

- **fix(packages): default agent `node_owner` to `"none"` — no controlcenter
  on a fresh node.** The bundled `yuneta_agent.json.sample` /
  `yuneta_agent22.json.sample` shipped `"node_owner": "owner"`, a placeholder.
  The agent starts the controlcenter client whenever `node_owner != "none"`
  (`c_agent.c` `mt_start`), so a freshly installed standalone node kept
  dialing `tcps://<arch>.owner.yunetacontrol.com:1994` and logging
  `getaddrinfo() FAILED` every ~17 s. The default is now `"none"`, the
  design's built-in off-switch, so a fresh box is quiet. Operators **with** a
  controlcenter still opt in with `YUNETA_OWNER=mycompany` at install time
  (the postinst sed now swaps `"none"` → their owner). Note: blanking the
  config to `{}` does **not** silence it — the framework default for
  `node_owner` is `""`, which is also `!= "none"`; `"none"` must be explicit.
  Affects fresh installs only (the `.json` files are conffiles, never
  overwritten on upgrade).
- **refactor(packages): minimal, sanitized agent config templates.** The two
  `*.json.sample` templates are trimmed to the smallest valid standalone
  baseline (the operator parameterizes per project afterwards): replaced the
  deprecated `authz.authz_yuno_role` key with `authz.authz_service` (the
  former is `SDF_DEPRECATED` in `c_authz.c`), and dropped the confusing
  `__realm_id__` override (`"/yuneta_agent.trdb"`) — the agent's treedb store
  dir is now inherited from the compiled-in `main.c` default
  (`/yuneta/store/agent/yuneta_agent.trdb`), exactly as a real parameterized
  node does. No `authz.jwks` / `authz.initial_load` in the template: the
  `root` role + `yuneta` user come from `main.c` via the config merge, so a
  fresh agent still bootstraps local `ycommand` on port 1991.

7.5.11

- **security(ext-libs): bump vendored OpenSSL 3.6.2 → 3.6.3.** Security patch
  release (`configure-libs.sh` v1.16, `TAG_OPENSSL=openssl-3.6.3`). Fixes one
  **High** CVE — CVE-2026-45447, heap use-after-free in `PKCS7_verify()` —
  plus a batch of CMS / QUIC / ASN.1 / AES CVEs (CVE-2026-34180..34183,
  35188, 42764..42770, 45445/45446, 7383, 9076). No API change (3.6 series).
  OpenSSL is linked **statically into every yuno**, so every yuno must be
  rebuilt + relinked to pick it up; the release CI builds ext-libs fresh, so
  the published `.deb`/`.rpm` get it automatically. Stayed on 3.6 (not 4.0),
  same LTS rationale as before.
- **feat(install): no prompt — `install.sh` runs straight through.** The
  installer no longer asks `Install the developer toolchain? [Y/n]` mid-run;
  it installs everything in one pass without stops. Use `--runtime-only` to
  skip the toolchain on a pure deployment box. (Served from `main`, so it
  ships on push.)

7.5.10

- **fix(deb): drop obsolete `libpcre3-dev` from the dev-deps helper.** PCRE1
  (`libpcre3-dev`) was removed from current Ubuntu (26.04) — it is "referred
  to but has no installation candidate", so the helper printed
  `[!] Failed: libpcre3-dev`. Yuneta does not need it (it builds its own
  static PCRE2, and the bundled nginx is static), so it is removed;
  `libpcre2-dev` stays. The `apt-cache show` guard didn't catch it (the
  transitional record still resolves), so the helper now just installs and
  reports the real failures.
- **fix(deb): honest dev-deps end summary.** The Debian helper ended with a
  vague `Dev environment setup attempt complete` and only printed failures
  inline; it now collects them and reports `all N packages installed` or the
  exact list that did NOT install, matching the `.rpm` helper.

7.5.9

- **fix(deb): never auto-reboot in `postinst`.** The Debian `postinst` forced
  a reboot at the end of install (auto-yes when non-interactive), which under
  `curl | sh` rebooted the box mid-flight — killing the SSH session before
  `install.sh` could install the developer toolchain. The kernel tuning is
  already applied live (`sysctl --system`), so a reboot is not required: it
  now only leaves the `reboot-required` hint and recommends a reboot, never
  forcing one. Matches the `.rpm` `%post` policy.
- **feat(install): `install.sh` installs certbot on both distros.** After the
  package, the installer now runs the bundled certbot helper (snap on Debian,
  EPEL `dnf` on RHEL) so TLS for the bundled web server is set up in the same
  run, regardless of `--runtime-only` (it is a runtime/ops tool).

7.5.8

- **fix(rpm): dev-deps helper used a dnf5-only flag that RHEL 9 rejects.**
  The 7.5.7 helper ran `dnf install --skip-unavailable`, but
  `--skip-unavailable` only exists in dnf5 (Fedora); RHEL 9 / Rocky 9 ship
  dnf4, which errors `unrecognized arguments: --skip-unavailable` and aborts
  the whole transaction — so the toolchain (git, clang, gcc, wget, …)
  installed nothing again. Now uses `--setopt=strict=0`, the dnf4-native way
  to skip unavailable packages (and valid on dnf5 too).
- **fix(rpm): create the `nogroup` group so the bundled nginx starts.** nginx
  falls back to its compiled-default group `nogroup`, which exists on Debian
  but not on RHEL; without it nginx aborted at startup
  (`getgrnam("nogroup") failed`). `%post` now creates it (RHEL-only, before
  the service starts).
- **fix(rpm): honest web-server start in the init script.** `start_web()` ran
  `nginx || true; log_end_msg 0`, printing `OK` even when nginx failed to
  start. It now captures the real exit code and reports it, like the agent
  start. (Matches the no-silent-failure rule.)
- **feat(install): `install.sh` is now a single cross-distro installer that
  sets up everything in one run.** It detects the distro (`apt` vs `dnf`),
  and on RHEL/Rocky/Alma enables **EPEL + CRB** first; pulls the matching
  package (`.deb` / `.rpm`) from the latest Release and installs it; then
  installs the **full developer toolchain** (git, mercurial, clang, gcc,
  cmake, ninja, wget, pipx, …) by delegating to the bundled, resilient
  `/yuneta/bin/install-yuneta-dev-deps.sh` — so a fresh box is build-ready
  from one command, with no second script to remember. Asks first when a
  terminal is attached (reads `/dev/tty`, so it works under `curl | sh`);
  installs by default when non-interactive. `--runtime-only` skips the
  toolchain for pure deployment boxes. Served from `main`, so it reaches
  users on push (it installs the latest published Release packages). Was
  Debian-only and runtime-only before.

7.5.7

- **fix(rpm): dev-deps helper no longer installs nothing when one package is
  unavailable.** `install-yuneta-dev-deps.sh` ran `dnf -y install "${PKGS[@]}"`
  as a single atomic transaction, so one unfindable package (typically a
  CRB-only `-devel`/`-static` when CRB was never enabled) aborted the WHOLE
  set — leaving `clang`, `wget`, `gcc`, `cmake` … none installed — and the
  `|| echo continuing` + final `[✓] complete` hid it. It now uses
  `dnf --skip-unavailable` (installs every available package, skips only the
  missing) and reports per-package with `rpm -q` which ones did NOT install,
  pointing at EPEL/CRB when a `-devel`/`-static` is absent, instead of a
  green lie. Matches the `.deb` helper's per-package resilience. (`wget` and
  `clang` are dev-deps installed by this helper, not by the base `.rpm`.)

7.5.6

- **fix(rpm): honest agent start in `%post`; don't source the `set -u`-unsafe
  RHEL init functions.** Two RHEL-only packaging bugs in the 7.5.5 `.rpm`
  (the `.deb` was never affected). (1) The generated `/etc/init.d/yuneta_agent`
  runs under `set -u`; on RHEL `/lib/lsb/init-functions` is absent so it fell
  to sourcing `/etc/init.d/functions`, which references unset vars
  (`SYSTEMCTL_SKIP_REDIRECT`…) and aborted the whole init script with
  "unbound variable" **before the agent was ever launched** — the binary was
  fine, the service "failed". It now defines the only two functions it uses
  (`log_daemon_msg`/`log_end_msg`) itself and only sources Debian's
  `set -u`-clean LSB file when present. (2) `%post` started the agent with
  `service start || true`, hiding a failed start behind RPM's always-"Complete"
  transaction. It now re-reads the effective `kernel.io_uring_disabled` after
  `sysctl --system`, only starts when io_uring is usable, captures the real
  result, and prints a loud "AGENT IS NOT RUNNING" warning with diagnosis
  hints (`systemctl status` / `journalctl` / `getenforce` for SELinux) instead
  of a green install over a dead agent.

7.5.5

- **refactor(packaging): split Debian packaging into `packages/deb/`.**
  With the new `packages/rpm/`, the Debian scripts moved from the root of
  `packages/` into a sibling `packages/deb/` (`AMD64`/`ARM32`/`ARMhf`/
  `RISCV64` wrappers + `make-yuneta-agent-deb.sh` + its `README.md`), so the
  two packagers are now symmetric: `packages/deb/` and `packages/rpm/`. The
  shared agent config samples stay at `packages/templates/` (referenced by
  both via an absolute `$YUNETAS_BASE/packages/templates/` path);
  `packages/README.md` is now a short index. The deb arch wrappers read
  `../../YUNETA_VERSION` + `../../RELEASE` (one level deeper); the release CI
  and the `.gitignore` `authorized_keys`/`webserver` rules follow the move.
  No change to the produced `.deb` or its contents.
- **feat(build): RHEL/Rocky/Alma build support (was Debian-only).**
  Yuneta now builds and runs on the RHEL family; verified end-to-end on
  Rocky Linux 9.7 (full static build + 110/110 ctest). New
  `install-dependencies.sh` auto-detects the distro from `/etc/os-release`
  and installs the right packages with `apt` (Debian) or `dnf` (RHEL,
  enabling EPEL + CRB). RHEL-specific build fixes that ride along:
  `configure-libs.sh` v1.15 forces `-DCMAKE_INSTALL_LIBDIR=lib` on the
  CMake libs (mbedtls/pcre2/jansson/argp), which on RHEL default to
  `lib64` and so were missed by the kernel's `outputs_ext/lib` link path
  (no-op on Debian); `set_compiler.sh` gained a `dnf reinstall` branch;
  and the postgres module includes `<libpq-fe.h>` (not
  `<postgresql/libpq-fe.h>`) with the dir resolved via `pg_config` in
  CMake, since the libpq header sits in `/usr/include` on RHEL vs
  `/usr/include/postgresql` on Debian. RHEL also needs `glibc-static`/
  `libstdc++-static`/`libxcrypt-static` (CRB) for the default static link.
- **feat(runtime): document the io_uring requirement on RHEL.** Yuneta's
  `yev_loop` is io_uring-based, and RHEL 9 / Rocky 9 / Alma 9 ship
  `kernel.io_uring_disabled=2` (fully disabled), so every yuno aborts at
  startup until it is re-enabled (`kernel.io_uring_disabled=0`). Called
  out in `installation.md`; `install-dependencies.sh` warns when it
  detects the disabled state. No code change — a deployment prerequisite.
- **feat(packaging): RPM packaging for the Yuneta Agent (`packages/rpm/`).**
  Counterpart of the Debian `packages/`: stages the same `/yuneta` payload
  and builds an `.rpm` with `rpmbuild` (`make-yuneta-agent-rpm.sh` +
  `x86_64`/`aarch64`/`riscv64` wrappers). RHEL-specific envelope: `.spec`
  instead of `control`, `%post`/`%preun`/`%postun` instead of
  maintainer scripts, `useradd`/`wheel`/`chkconfig`/langpacks/EPEL-certbot,
  and the shipped `kernel.io_uring_disabled=0`. Built + inspected on Rocky
  9.7 (`rpm -qlp`/`--scripts`/`rpmlint`); not installed.
- **ci(release): `release-deb.yml` -> `release-packages.yml`, now also
  publishes the x86_64 `.rpm`.** The release job builds the `.rpm` next to
  the AMD64 `.deb` and uploads both as release assets. It runs on the same
  Ubuntu runner: the default build is fully static, so the binaries also
  run on RHEL/Rocky — no EL9 container needed.
- **fix(tests): make `ytls/test_cert_info` portable to OpenSSL >= 3.5.**
  The expired-cert helper used `openssl x509 -req -days -1`, which
  OpenSSL >= 3.5 rejects ("end date before start date"). It now falls back
  to `-not_before/-not_after` with fixed past dates (OpenSSL >= 3.2) when
  the negative-span form fails, keeping older OpenSSL (e.g. Debian 12's
  3.0.x) working. Not RHEL-specific — any host with a modern OpenSSL.
- **fix(emu_device): don't truncate the final frame on standalone exit.**
  `finish_replay()` called `exit(0)` right after queueing the last frames,
  before io_uring completed the write — the final frame could be lost. In
  standalone CLI mode it now waits for the C_TCP `EV_TX_READY` drain signal
  (tx queue empty + in-flight write completed) and exits from `ac_tx_ready`;
  empty replays still exit immediately. Verified end-to-end: a 3-frame replay
  delivered all bytes including the last frame, clean exit. Agent-managed
  mode is unchanged (it never exited).
- **chore(auth_bff): remove the deprecated `idp_url` + `realm` pair.** The
  legacy Keycloak path-scheme fallback (build
  `<idp_url>/realms/<realm>/protocol/openid-connect/{token,logout}`) was
  `SDF_DEPRECATED` since the 2026-04-30 OIDC migration and a release has
  shipped with the warning. Both `SDATA` attrs and the resolution branch in
  `c_auth_bff` `mt_create` are gone; configure `issuer` (discovery) or the
  explicit `token_endpoint` + `end_session_endpoint` instead. No yunetas or
  private deployment still set the legacy pair (all on `issuer`). Docs
  updated (`YUNO_AUTH.md` §2.5, `guide_oauth2_pkce_bff.md`). The now-subjectless
  `tests/c/c_auth_bff/test17_legacy_idp_url` is removed; the suite is 18/18.
- **docs(auth): ROPC in `c_task_authenticate` deferred by design.** The CLI
  grant stays `grant_type=password` (works on Keycloak, the only deployed
  IdP). Documented the constraint and the real migration path (device-flow
  for interactive + client-credentials for headless CI, not loopback PKCE —
  the six CLI callers have no browser) in the `c_task_authenticate.c` header,
  `YUNO_AUTH.md` §3.4, and `TODO.md`. No behavior change.

v7.5.4 -- 08/Jun/2026

- **feat(mqtt/security): subscribe-side ACL enforcement.** Completes the
  publish/subscribe ACL started in 7.5.3. The per-topic SUBACK reason is
  built in the broker's `ac_mqtt_subscribe`, so the check lives there
  (alongside the existing `deny_subscribes` gate), calling
  `mqtt_acl_check(…, "read")` per requested filter — a denied filter is not
  added and gets a `MQTT_RC_NOT_AUTHORIZED` (v5) / `0x80` (v3.x) SUBACK
  reason, logged. Unchanged when `enable_acl` is off or a group has no
  `subscribe_acl` patterns.
- **fix(security/authz): per-command authz gate redesign.** A local agent
  pilot showed the 7.5.3 gate (`enable_command_authz`) was undeployable — it
  denied a yuno's own internal startup commands (e.g. `open-treedb`) and the
  yuno exited. Two bugs fixed in gobj-c: (A) a specific-authz lookup on a
  concrete gobj now falls back to the **global** authz table
  (`authzs_list`), so `__execute_command__` resolves on any gobj (was
  "authz not found" → deny-all, root included); (B) the gate fires **only
  for external commands** (those whose kw carries the authenticated
  `__username__` injected by `c_ievent_srv`), so internal `gobj_command()`
  calls are never gated (`command_parser`). Re-piloted: the agent now boots
  clean with the gate on and `ycommand` (root) works. `enable_command_authz`
  remains **default-off**.
- **feat(security/authz): seed root role model** in the C_AUTHZ yunos that
  lacked one (`controlcenter`, `mqtt_broker`, `emailsender`) via
  `Authz.initial_load` (role `root` + user `yuneta`, mirroring the agent),
  a prerequisite for enabling the command-authz gate there.

v7.5.3 -- 08/Jun/2026

- **feat(security): per-command authorization re-armed (gated opt-in).** The
  `SDF_AUTHZ_X` check at the command-dispatch boundary in `command_parser.c`
  (commented out for years) now runs again, but only when the yuno sets the
  new `enable_command_authz` attr (`c_yuno`, `SDF_RD`, default `"0"`), so the
  default posture is unchanged and non-breaking. Self-issued commands
  (`src == gobj`) bypass the check; a denial returns `-403` and is logged
  (`MSGSET_AUTH`). Turning the gate on requires a running `C_AUTHZ` role
  model (the global checker is fail-closed). Test:
  `tests/c/command_authz/`.
- **feat(mqtt): publish-side ACL (model A, group-based, default off).** New
  `enable_acl` attr on `C_MQTT_BROKER` plus `publish_acl`/`subscribe_acl`
  array columns on the `client_groups` topic (schema_version 25→26,
  topic_version 3→4; additive). `C_PROT_MQTT2` queries the broker over a
  direct `EV_MQTT_ACL_CHECK` event on PUBLISH; allow when ACL off or no
  patterns authored, deny unknown clients, deny logged. Subscribe-side
  wiring is staged (schema + helper ready) but not yet enforced. Test:
  `tests/c/c_mqtt/acl`. Also fixed a latent fkey bug: the helper now passes
  `{fkey_only_id:1}` so `client_groups` resolves as plain ids (otherwise the
  ACL silently allowed all).
- **fix(emu_device): frame emission path.** `window`/`interval` were coerced
  to 0 (CLI values passed as `json_string` into `DTP_INTEGER` attrs;
  `cmd_write_*` used `kw_get_str`+`atoi`), so the replay sent nothing. Now
  `json_integer(atoi(...))` on the CLI side and `kw_get_int(KW_WILD_NUMBER)`
  in the commands; also freed the replay resources on `mt_play` error paths
  and log a skipped record with no `frame64`.
- **refactor(emu_device): moved from `yunos/c/` to `utils/c/`.** It is a
  standalone CLI utility yuno (a device-gate emulator run by hand for
  testing), not a deployable service — now installed to `/yuneta/bin` like
  the other `utils/c` tools.
- **docs:** `YUNO_AUTH.md` rewritten to describe the gated authz (was
  documented as "commented out", including the auth-flow diagram);
  `mqtt_broker.md` gains an Authorization (ACL) section.

v7.5.2 -- 06/Jun/2026

- **security: hardening batch (memory-safety + injection across the stack).**
  - **gobj-c:** NULL-guard in `gbuffer_deserialize`; bounded recursion in
    `kw_find_path` and the kwid comparators (hostile-JSON stack exhaustion).
  - **root-linux:** NUL-terminate accumulated URL / header field / header
    value in `ghttp_parser` (over-read fed to `json_string`); guard
    `/auth/logout` against an uninitialized refresh-token read.
  - **ytls:** re-entrant use-after-free in `encrypt_data` WANT path + a
    double `gbuffer_get` (stream corruption).
  - **yev_loop:** bound DNS response parsing + unpredictable transaction id;
    defer event free until in-flight io_uring CQEs drain (UAF).
  - **timeranger2:** validate on-disk md2 `__offset__`/`__size__` before
    read AND before the `delete_instance` payload wipe (cross-record
    overwrite); guard `read_md` return; bound the inotify parse loop.
  - **libjwt:** backport GHSA-q843-6q5f-w55g algorithm-confusion JWT forgery
    fix + the full cfd8902 hardening; add an in-tree regression test
    (`tests/c/libjwt/`, RSA/EC/EdDSA/`alg:none`).
  - **modbus:** reject MBAP length < 3 (heap overflow). **dba_postgres:**
    escape SQL identifiers/literals in insert + create-table (SQLi).
  - **emailsender:** reject CR/LF in header/envelope fields (SMTP/MIME
    injection). **mqtt:** reject property-length underflow in C_PROT_MQTT2.

v7.5.1 -- 03/Jun/2026

- **fix(treedb): multi-version parent reverse-hook hygiene.** Two
  in-memory hook quirks around versioned (pkey2) parents are fixed at the
  treedb layer:
    - **Unlink targeted only the primary parent version.** A child's fkey
      ref carries just `parent_topic^parent_id^hook` (no version), so
      `treedb_clean_node` unlinked from the PRIMARY instance, leaving a
      stale entry on the non-primary version the child was actually hooked
      on. It now locates the parent-version instance that really holds the
      child (`find_parent_version_holding_child` over the pkey2 index) and
      unlinks that one; the primary-instance behaviour is the fallback for
      hook+fkey combos the read-only probe can't match.
    - **Duplicate hook entries.** Repeated create/link of the same child id
      left it more than once in the parent hook (`yunos:["5000","5000"]`),
      inflating "Using in N". `_link_nodes` and the loader
      `link_child_to_parent` now dedup by child id before appending
      (idempotent link), and the child-side fkey array is deduped too.
  A skipped duplicate is **warned** (not silently swallowed): a re-link, or
  a duplicate fkey self-healed on load, is surfaced via `gobj_log_warning`.
  Closes the TODO follow-up; both quirks also self-heal on reload.

v7.5.0 -- 03/Jun/2026

- **fix(agent): version-aware, stale-safe `delete-config`/`delete-binary`
  usage guard.** The "Using in N yunos" guard read the raw `yunos` hook
  count, which is config-id-level (shared across versions) and can carry
  stale/duplicate refs — so an UNUSED config/binary version could not be
  pruned while another version was in use, and lingering refs blocked
  deletes. New `count_yunos_using()` validates every hooked yuno id via
  `gobj_get_node` (a deleted yuno → NULL → skipped) and, when a version is
  given, counts only yunos pinned to THAT version (config↔`name_version`,
  binary↔`role_version`). So an unused/superseded version prunes cleanly,
  only the in-use version blocks, and `force=1` overrides. Combined with the
  durable per-instance delete below, `delete-config version=`/`delete-binary
  version=` now remove a single version durably. (Underlying treedb
  multi-version reverse-hook hygiene remains a minor follow-up — see TODO.md.)
- **feat(treedb): durable per-instance (pkey2) node delete.**
  `treedb_delete_instance` now tombstones EVERY md2 row belonging to a
  `(key, pkey2_value)` via `tranger2_delete_instance()` (enumerated with a
  transient disk list) and drops the in-memory pkey2 slot — previously it
  only dropped the in-memory slot, so the instance resurrected on the next
  reopen (a treedb instance spans several rows: create + each link/update
  re-appends one with the same id/pkey2; tombstoning just the latest let an
  earlier row reload). The primary index is untouched (callers route only
  non-primary instances; the loader re-elects the highest surviving rowid).
  Whole-key delete (`treedb_delete_node`/`tranger2_delete_key`) is unchanged.
  Exposed through the agent: `delete-yuno`/`delete-config`/`delete-binary`
  now accept the pkey2 (`yuno_release`/`version`) to prune a single
  non-primary release/version, listing via `gobj_list_instances` and
  reading the running-guard from the primary (instance records carry a
  stale `yuno_running`). New regression test covers a multi-row instance +
  close/reopen. (Remaining follow-up: stale reverse-hooks on linked parents
  — see TODO.md.)
- **fix(agent): `find-new-yunos` inherits node placement across a version
  bump.** A version-bump deploy (`install-binary` + `find-new-yunos
  create=1` + `deactivate-snap`) created the fresh `yunos` row at schema
  defaults, dropping the operator-set `start_priority`/`sched_priority`/
  `cpu_core` and forcing a re-run of `tools/agent/set_start_priorities.py`.
  `cmd_find_new_yunos` now copies those three fields from the prior primary
  row into the emitted `create-yuno` command (and `pm_create_yuno` accepts
  them), so launch tiers and CPU placement survive the bump. Same-version
  REBUILD hot-patches were already unaffected (they keep the existing row);
  genuinely-new yunos still get defaults plus the `util`-tag seed.

v7.4.8 -- 03/Jun/2026

- **feat(agent): per-yuno `start_priority` launch tiers.** The agent's
  `yunos` topic gains `start_priority` (band 0..9, default 5). `run-yuno`
  launches ascending (utilities first), `kill-yuno`/`pause-yuno` descending
  (utilities last, so logcenter captures everyone's shutdown), stable within
  a tier. The node-wide relaunch (`run_enabled_yunos`, used by
  `restart_nodes`/`deactivate-snap` and at startup) honours the same order;
  the force-SIGKILL pass stays unordered (no graceful drain to sequence).
  `create-yuno` seeds `start_priority=1` for `util`-tagged yunos (the set
  `run_util_yunos` already starts first) — no app role names in the agent.
  Schema `topic_version` 19→20 + `schema_version` 22→23; the bump only
  refreshes the col schema files, record data is untouched (no store wipe).
  Assign app tiers per node with `tools/agent/set_start_priorities.py`.
- **feat(agent): node CPU placement (`sched_priority`, `cpu_core`) from the
  agent treedb.** Both are new `yunos` columns the agent injects into the
  launched yuno's config as its `sched_priority`/`cpu_core` attrs, so OS
  scheduling/affinity is a node-local decision instead of being baked into
  the config that travels across nodes. Defaults only: the user config file
  is merged after the agent's and still wins. `cpu_core=0` (default) = no
  boost, unchanged behaviour.
- **refactor(c_yuno): scheduling attr `priority` renamed to `sched_priority`.**
  The `sched_setscheduler` attr (default 20, applied only when `cpu_core>0`)
  collided with the per-service start order (0..9) and the agent's
  `start_priority`; renamed so the name states what it does. `SDF_PERSIST`
  fallback is a no-op in practice (consulted only at `cpu_core>0`, which no
  shipped yuno sets); no migration shim. The per-service `priority` is
  unchanged. See `TODO.md`.
- **refactor(agent): `set-ordered-kill` renamed to `set-graceful-kill`.** The
  command never ordered anything — it only sets `signal2kill=SIGQUIT` (the
  yuno catches it and shuts itself down cleanly). Renamed to the honest axis
  (graceful SIGQUIT vs quick SIGKILL, `set-quick-kill`), which also frees
  "ordered" for the real `start_priority` ordering above. No alias kept.
- **feat(tools): `set_start_priorities.py` — assign `start_priority` by role.**
  One-shot operator tool mapping each managed yuno's role to a launch tier
  (defaults: utilities=1, `gate_*`=4, `db_*`=7; unmatched left as-is) and
  writing the differences via `update-node` (record base64'd into
  `content64`; the inline `record={...}` form is not coerced by the CLI).
  `--rule PATTERN=PRIO` adds/overrides (matched before the built-ins),
  `--dry-run`/`--all`/`--show-all`. Same OAuth2-once + `-j` plumbing as the
  other agent scripts, so it can drive a remote wss:// agent.
- **feat(tools): `sync_binaries.py`/`sync_configs.py` order restarts by
  `start_priority`.** Both bounced yunos alphabetically; now they read the
  per-yuno `start_priority` the agent exposes via `*list-yunos` and restart
  ascending, so infrastructure comes back before its dependents. Both
  degrade to the previous order when the agent has no `start_priority` yet.
  The quit/decline message also reads `Cancelled - no changes made.` instead
  of `Aborted.` (which looked like a crash).
- **feat(c_yuno): `print-role` command — runtime equivalent of `--print-role`.**
  Every yuno (the agent included) now answers a `print-role` command that
  returns its basic identity: `role`, `name`, `alias`, **`version`** (the
  yuno's own APP_VERSION) and **`yuneta_version`** (the framework version),
  plus description/tags/required_services/public_services/service_descriptor.
  Until now that info was only printable offline via the binary's
  `--print-role` flag; there was no way to read a *running* yuno's version.
  Lives in C_YUNO's command table, so it is inherited by all yunos. Address
  the yuno gobj with the `-S __yuno__` flag: `ycommand -S __yuno__ -c
  'print-role'` (inline `service=...` is a command parameter, not routing).
- **feat(tools): `sync_binaries.py` automates the same-version REBUILD
  hot-patch.** A `REBUILD` (`update-binary`) overwrites the slot the running
  yuno executes from, so it failed with `text-file-busy` and the script only
  printed a "kill-yuno first" reminder. Now, once both confirmation gates are
  cleared, it runs the documented per-role cycle itself, scoped by
  `yuno_role` (never node-wide): `kill-yuno` (only if running; orderly
  SIGQUIT, so the gbmem audit runs) → poll `*list-yunos` until the process
  exits → `update-binary` → `run-yuno play=0` (if it was running) →
  `play-yuno` (if it was playing). Prior run/play state is read from
  `*list-yunos` and restored per role, and a role with several instances
  across realms is handled in one shot. New `--no-restart` flag keeps the old
  print-only behaviour. The version-bump path (`find-new-yunos` +
  `deactivate-snap`, a node-wide bounce) stays a reminder.
- **feat(tools): `sync_configs.py` gains an opt-in `--restart`.** Installing
  a config does NOT need a kill — unlike `update-binary` (which hits
  `text-file-busy` while the yuno runs), a config push always succeeds on a
  running yuno; it just does not take effect until that yuno next (re)starts.
  So by default the script still only pushes and prints the affected yuno ids
  (from the agent record's `yunos` field) as a `kill-yuno` + `run-yuno`
  reminder — restarting is a separate, optional step. Pass `--restart` to
  also bounce the using yunos right away, scoped by yuno `id` (never
  node-wide): `kill-yuno` (only if running; orderly SIGQUIT) → poll
  `*list-yunos` until it exits → `run-yuno play=0` → `play-yuno` (if it was
  playing), preserving prior run/play state. A stopped yuno is left stopped;
  NEW configs (no agent record) print a reminder.

v7.4.7 -- 02/Jun/2026

- **feat(c_authz): `create-user` password is now optional.** KC/IdP-
  authenticated users have no local password (`credentials` null) — auth is
  by JWT. The command no longer rejects an empty password; it only hashes
  credentials when one is given, otherwise creates the user password-less,
  the same way `register-idp-user` and the `initial_load` users do.
- **fix(c_authz): stop resetting a user's "Created Time" on update.** In
  `ac_create_user` the `new_user` flag was inverted (`user?TRUE:FALSE` is
  TRUE when the node already exists), so updating an existing user wrote
  `time=now` into the record, overwriting its creation timestamp. New users
  were unaffected (treedb auto-stamps the `time`-flagged column on create).
  Corrected to `user?FALSE:TRUE`.
- **fix(yuno_agent): silence spurious "Event NOT DEFINED in state" on every
  login.** C_AGENT subscribes to all of its `authz` service's output events
  but had no FSM entry for `EV_AUTHZ_USER_LOGIN`/`LOGOUT`/`NEW` (consumed by
  controlcenter from its own local authz), so each login/logout logged an
  error. Added accept-and-ignore handlers.
- **fix(ycommand): accept `EV_ON_OPEN_ERROR` in `ST_DISCONNECTED`.** A failed
  connect / identity-card NAK publishes `EV_ON_OPEN_ERROR` after the close;
  the FSM lacked it and logged "Event NOT DEFINED in state". Now handled like
  `EV_ON_ID_NAK` (`ac_on_close`).
- **feat(tools): sync_binaries.py / sync_configs.py — OAuth2 passthrough for
  remote agents.** Both scripts now log in ONCE (Keycloak password grant via
  stdlib, or a `--jwt` passed verbatim) and thread the token through `-j` to
  every `ycommand` call, so they can drive a remote `wss://` agent without
  SSH. New flags: `-I/--issuer` (OIDC discovery), `-T/--token-endpoint`,
  `-Z/--client-id`, `--client-secret`, `-x/--user-id`, `-X/--user-passw`,
  `-j/--jwt`. The no-arg local path is unchanged (no auth). `$$()` already
  resolves client-side, so the LOCAL build is what gets uploaded.
- **fix(emailsender): handle `EV_ON_OPEN` in `ST_WAIT_RESPONSE`
  (reconnect-on-demand).** When the SMTP link had idle-closed, the head
  message is dispatched anyway and `c_smtp_session` reconnects to deliver
  it; on reaching `ST_IDLE` it publishes `EV_ON_OPEN` *before* beginning the
  stashed message — but `c_emailsender` is already in `ST_WAIT_RESPONSE` (it
  changes state before `EV_SEND_MESSAGE`), so every reconnect-to-deliver
  cycle logged a spurious *"Event NOT DEFINED in state"* even though the
  mail was delivered. `ST_WAIT_RESPONSE` now accepts `EV_ON_OPEN` via
  `ac_on_open_waiting`, which only marks the link ready and does NOT
  re-dequeue (a message is already in flight). Also corrected the misleading
  `ac_disconnected` comment in `c_smtp_session`.
- **feat(c_authz): IdP (Keycloak) user provisioning.** New commands
  `register-idp-user` (create the user in Keycloak via the admin REST API +
  the local `treedb_authzs` user with the chosen role, then email a
  set-password invite), plus `set-kc-config` / `view-kc-config` to configure
  the admin connection. The connection params are neutral persistent attrs
  (`kc_*`, `SDF_PERSIST`) set at runtime — no endpoints/secrets in code or
  committed config; the secret is masked by `view-kc-config`. Lives in
  C_AUTHZ so every auth-enabled yuno inherits it. The outbound work is an
  async multi-job `C_TASK` over a lazily-created `C_PROT_HTTP_CL`.
- **feat(c_prot_http_cl): accept any JSON value as the request body.**
  `data` is now read with `kw_get_dict_value` instead of `kw_get_dict`, so a
  JSON array body (e.g. Keycloak `execute-actions-email`) is sent verbatim
  via `json_dumps`; the x-www-form-urlencoded path is unchanged (it only
  iterates objects).
- **fix(packages): cert-sync no longer reloads TLS on every tick.**
  `copy-certs.sh` re-copied the certs each run (mtime bump → spurious
  `reload-certs` broadcast every 15 min): GNU `install -C` never skips a
  symlink source (letsencrypt `live/*.pem`) and re-copies on root/yuneta
  owner mismatch. Now resolves the symlink with `readlink -f` and sets the
  owner via `install -o/-g`, dropping the trailing `chown`.

v7.4.6 -- 01/Jun/2026

- **feat(tools): `tools/agent/sync_binaries.py` — reconcile built yunos
  against the agent and push updates.** Drives from the agent's installed
  binaries (`ycommand -c '*list-binaries'`), looks each one up in
  `outputs/yunos` (`--print-role`), and classifies it
  BUMP/DOWNGRADE/REBUILD/UP-TO-DATE/NO-BUILD. After confirmation it runs
  `install-binary` / `update-binary id=<role> content64=$$(<role>)` for the
  chosen roles; it does not automate the node-wide lifecycle steps
  (`kill-yuno`, `find-new-yunos` + `deactivate-snap`) but prints them as
  reminders. Lives under `tools/` (shipped in the install `.deb`, usable on
  a bare node), and is documented at doc.yuneta.io under the new **Tools**
  section.

- **feat(tools): `tools/agent/sync_configs.py` — reconcile a directory's
  configs against the agent and push updates.** Config-side sibling of
  `sync_binaries.py`. Because configs are not centralized like binaries
  (they live under each yuno's `batches/<host>/`), it drives from the
  current directory: each `*.json` config's id is its filename (minus
  `.json`) and its version is the `__version__` field inside the file
  (`_*.json` batch helpers and files without `__version__` are skipped). It
  looks each up via `ycommand -c '*list-configs'` and classifies it
  NEW/BUMP/UPDATE/UP-TO-DATE/DOWNGRADE/agent-only. After confirmation it runs
  `create-config` / `update-config id='<id>' content64=$$(<path>)`; a
  DOWNGRADE (local older than the agent) is reported but never pushed. It
  prints the affected yuno ids as a `kill-yuno` + `run-yuno` reminder rather
  than automating the restart. Documented at doc.yuneta.io under **Tools**.

- **feat(yuno_agent): `install-config` alias for `create-config`.** Added by
  analogy with `install-binary`, so config installs read symmetrically with
  binary installs (`c_agent.c`). Corrected the config-command docs that this
  exposed as stale: `YUNO_LIFECYCLE.md` claimed there was no `install-config`
  and that `update-config` "creates and updates" (it only overwrites an
  existing `(id, version)`); the onboarding recipes in `YUNO_LIFECYCLE.md` /
  `SCAFFOLDING.md` / `YUNO_AUTH.md` used `update-config … version=<v>
  zcontent=$$()` — the real form is `create-config … content64=$$()` (version
  read from the file's `__version__`).

- **chore(packages): yuno binaries + `tools/agent` on PATH per layout.** In
  `make-yuneta-agent-deb.sh` the hardcoded `outputs/yunos` PATH entries moved
  into the profile snippet's layout-detection branch (full source tree vs
  deployed `.deb` node), and `tools/agent` was added there too, so
  `sync_binaries.py` / `sync_configs.py` are runnable by name on a node.

- **chore(tools): retire `tools/docs-migration/`.** The myst migration is
  done and the Quarto pilot was abandoned, so the two one-off helpers
  (`myst_to_quarto.py`, `strip_toctrees.py`) were removed.
  `verify_api_coverage.py` is a repo-dev verifier (not a node tool), so it
  moved to `scripts/`; its five stale "extra" reports were resolved by
  correct header→landing mapping (no docs removed). `scripts/` is repo-only;
  `tools/` ships in the `.deb`.

v7.4.5 -- 30/May/2026

- **fix(yuno_agent): `delete-yuno`'s snap-tag guard read the wrong metadata
  key — it was dead.** `cmd_delete_yuno` read `__md_treedb__`__tag__`, but
  the metadata key is `tag` (set in `tr_treedb.c`; the kernel guard
  `treedb_delete_node` reads `__md_treedb__`tag`). So the agent-level guard
  always saw 0 and never fired — only the kernel `treedb_delete_node`
  backstopped the actual delete (with a cryptic message), and the bogus
  `KW_REQUIRED` on a missing key risked log noise. Fixed to read
  `__md_treedb__`tag` with flag 0 (default 0 = untagged), matching the
  kernel guard and the `delete-binary` guard, and clarified the message to
  *"tagged by snap N (rollback)"*. Found while adding the `delete-binary`
  guard. Verified the key is populated: `snap-content name=<tag>
  topic_name=yunos` lists the tagged yuno records.

- **fix(yuno_agent): `delete-binary` refuses to purge a binary a snap
  references (clear reason).** A snap pins the binaries it captured:
  `shoot-snap` stamps its id on each topic's current-primary record
  (md2 `user_flag`, surfaced as `__md_treedb__.tag`), `binaries` included,
  and `activate-snap` rolls back to exactly those records — so the binary
  file must survive or `run-yuno` fails with *"primary binary not found"*.
  The kernel `treedb_delete_node` already refuses a tagged node unless
  `force`, and `cmd_delete_binary` breaks before the `rmrdir` when the node
  delete fails — so the file was never actually lost. But unlike the
  sibling `delete-yuno`, `delete-binary` gave no reason (just a cryptic
  kernel log + a generic failure). Added the explicit agent-level guard
  (mirroring `delete-yuno`, reading `__md_treedb__.tag`): a snap-tagged
  binary is refused with *"referenced by snap N (rollback)"* and `force=1`
  overrides — which breaks that snap's rollback, as documented. Verified
  the mechanism live: `snap-content name=<tag> topic_name=binaries` lists
  the exact binary records the snap pinned.

- **fix(yuno_agent): `list-binaries` shows the binary in use, not every
  instance.** `cmd_list_binaries` had been switched (df0e50e70) from
  `gobj_list_nodes` to `gobj_list_instances`, which made it return one row
  per `(role, version)` — identical to `list-binaries-instances`, and after
  a same-version `update-binary` (an append) even two rows for the same
  `(role, version)`. The reason given at the time ("the new instance is
  invisible in the primary index until `deactivate-snap`") was the pkey2
  staleness bug since fixed in `dbf532ec9`. Reverted `list-binaries` to
  `gobj_list_nodes("binaries", …)`: ONE node per role — the primary, i.e.
  the binary actually in use. `list-binaries-instances` keeps the full
  `(role, version)` enumeration. Verified live: `list-binaries` returns 15
  rows (one per role, the in-use version) while `list-binaries-instances`
  returns 30 (every record). The primary correctly tracks the in-use
  version — an `update-binary` updates it in place; an `install-binary` of
  a new version only changes it once `deactivate-snap` promotes+reloads it
  (correct: the new binary is not in use until then).

- **feat(ycommand): `history` / `!history` work non-interactively, and
  fix the local-command hang.** Two problems with the history command
  outside `-i`: (1) the line editor (`C_EDITLINE`, `priv->gobj_editline`)
  is only created in interactive mode, and both `list_history()` (the bare
  `history` intercept) and `cmd_local_history()` (the `!history`
  local-table entry) read only that live editor — so `ycommand history` /
  `ycommand -c history` printed nothing even though the history is
  persisted to `~/.yuneta/history2.txt`. Both now fall back to that file
  when there is no live editor. (2) A trailing **local** command in
  non-interactive mode hung: the shutdown timeout is scheduled from
  `ac_command_answer`, which only fires for **remote** commands (a local
  `history` produces no `EV_MT_COMMAND_ANSWER`), so the queue drained and
  ycommand waited forever for an answer that never came. Added
  `schedule_exit_if_done()` at the tail of `run_next_pending()` — when the
  queue is empty, the session is non-interactive, no async command is in
  flight, and we are not in long-lived stdin-pipe mode, it schedules the
  same shutdown timeout. Interactive sessions and pipe mode (which waits
  for EOF) are unaffected.

- **feat(snap-content): friendlier snap inspection.** The agent's
  `snap-content` (served by `C_NODE` in `c_node.c`) required the numeric
  `snap_id` AND an exact `topic_name`, so you could not ask "where does
  this snap point?" without already knowing the topic names. Two additive,
  backward-compatible changes: (1) the snap is now selectable by
  `snap_id`, `id` (alias), or `name` (resolved against `__snaps__`); the
  legacy `snap_id=` keeps working. (2) `topic_name` is now optional — when
  omitted, the command returns the **overview** of every topic the snap
  tags and how many records each (a cheap count-only walk via a new
  `snap_count_cb`, not a full load), e.g. `snap-content name=pre-744` →
  `realms:3, yunos:16, binaries:15, configurations:16, public_services:2`.
  Pass `topic_name=<topic>` to drill into one topic's foto as before. The
  `id`/`name` params were added to the param schema in both `c_node.c` and
  the agent's `c_agent.c`.

v7.4.4 -- 30/May/2026

- **fix(c_websocket): stop synthesizing `EV_ON_OPEN_ERROR` at the
  transport layer.** `EV_ON_OPEN_ERROR` is a high-level event owned by
  the session layer (`c_ievent_cli`, which emits it with the remote-yuno
  identity). The commit that introduced it ("EV_ON_OPEN_ERROR — close
  before open") also added an emission in `c_websocket` `ac_disconnected`
  for the "transport closed before the WS upgrade completed" case. That
  emission is mislayered and has no consumer: no FSM declares
  `EV_ON_OPEN_ERROR` as an input action, so `c_websocket` publishing it to
  its parent (`C_CHANNEL`, sitting in `ST_CLOSED` because it never opened)
  was rejected by `gobj_send_event` with "Event NOT DEFINED in state". On
  slower nodes the `run-yuno` reconnect window widens and the race fired
  once per affected yuno (1:1 with the close-before-upgrade warning).
  `ac_disconnected` now publishes only `EV_ON_CLOSE` when a real session
  existed, otherwise returns silently (pre-918be48b9 behavior), and
  `EV_ON_OPEN_ERROR` was dropped from `c_websocket` `event_types`. The
  high-level emission in `c_ievent_cli` is unchanged.

- **fix(c_websocket): raise default `timeout_handshake` 5s → 30s.**
  During a mass yuno launch (`kill-yuno` + `run-yuno`) every yuno's
  `agent_client` (`C_IEVENT_CLI` → … → `C_WEBSOCKET`) reconnects to the
  single-threaded agent at once; the agent's event loop is stalled doing
  launch work (loading binaries, fork/exec, treedb) and could not complete
  each WS upgrade handshake within the old 5s window for the yunos at the
  back of the queue → "Timeout waiting websocket handshake" in synchronized
  bursts (one per launched yuno). The timeout firing was counterproductive:
  it `ws_close` + `EV_DROP`s and reconnects after
  `timeout_between_connections`, adding load to the very herd that caused
  it. The new 30s default sits comfortably above the observed agent
  loop-stall during mass launch; the attr is per-instance configurable
  (`SDF_PERSIST`) so a public-facing WS server that wants faster dead-peer
  detection can still tighten its own. The remaining root-cause work
  (jitter on `timeout_between_connections` in `c_tcp` to break the
  synchronized reconnect herd) is not addressed here.

- **feat(yuno_agent): single command response for `run-yuno`, plus a
  `play` knob.** Scripts driving the agent need exactly ONE answer per
  command to stay in sync; `kill-yuno`/`pause-yuno`/`play-yuno` already
  do, but `run-yuno` emitted ~2N answers over N yunos. Two independent
  causes were fixed: (1) `cmd_run_yuno` created one `C_COUNTER` with
  `max_count=1` INSIDE the per-yuno loop (one answer each); it now
  aggregates the `EV_ON_OPEN` filters into a single counter with
  `max_count=total` AFTER the loop, mirroring kill/pause/play — one
  `"N yunos found to run"` answer. (2) The implicit auto-play: on connect
  `ac_on_open` reconciles `must_play` by calling `play-yuno`, one async
  answer per yuno. A new `run-yuno play=0` parameter (default `1`,
  backward-compatible) launches the process(es) WITHOUT auto-play, so a
  script does `run-yuno play=0` (1 answer) then `play-yuno` (1 answer,
  aggregated over already-running yunos). The suppression is per-launch and
  kept **in-memory in the agent**: `run-yuno play=0` records each
  `launch_id` in `priv->no_play_launches`, and `ac_on_open` consumes it by
  matching the connecting yuno's `identity_card`launch_id`, deleting the
  marker on first connect. It is NOT a treedb column and does NOT mutate the
  persistent `must_play`; a watcher crash relaunch reuses the same
  `launch_id` but the marker is already gone, so autonomous `must_play`
  recovery is untouched.

- **fix(tr_treedb): refresh the pkey2 secondary index on a runtime
  `treedb_save_node()`.** The secondary `pkey2` index kept objects
  SEPARATE from the primary `id` index, populated only while loading
  from disk (`load_pkey2_callback` gated on `sf_loading_from_disk`).
  At runtime `treedb_update_node()` mutated the primary node in place
  and `treedb_save_node()` only appended a tranger row — neither
  touched the secondary index, so `treedb_get_instance()` /
  `treedb_list_instances()` returned the OLD content after an update.
  Surfaced as the agent's `list-binaries` showing the previous binary
  right after a successful `update-binary` (the agent returns the
  in-memory pkey2 index; the new record was already on disk). Now
  `treedb_save_node()` re-points every pkey2 slot of the node at the
  node object itself. No-op for topics without pkey2s. New regression
  test `tests/c/tr_treedb_update_instance` (create → reload from disk →
  update → assert via get_instance/list_instances); it fails against
  the pre-fix code.

- **fix(emailsender): correctness + resilience of the SMTP send path.**
  (1) Duplicate `MAIL FROM` → "503 MAIL already given": on AUTH-OK the
  session published EV_ON_OPEN (whose subscriber already begins the
  queued send, being idle) and then began it again; snapshot the
  pending flag before publishing. (2) Permanent 5xx rejections now
  dead-letter immediately (reply code forwarded via EV_ON_CLOSE /
  EV_ON_MESSAGE; `code>=500` = permanent, 4xx/timeout/drop = transient
  retry). (3) Binary (non-UTF-8) bodies persisted base64 under
  `body_base64` instead of being silently dropped by `json_stringn`.
  (4) Reconnection is owned by `c_smtp_session`, not the sender: after a
  `timeout_inactivity` idle-close, `c_emailsender` just dispatches the head
  message (it no longer carries any reconnect timer/backoff), and
  `c_smtp_session` — which must redo the handshake — reconnects its bottom
  `C_TCP` on `EV_SEND_MESSAGE` in `ST_DISCONNECTED` and sends on reaching
  `ST_IDLE`. A handshake failure (AUTH 535, EHLO/banner 5xx) is transient
  for the in-flight message (the server never saw it); only a 5xx in the
  message's own MAIL/RCPT/DATA transaction dead-letters it. (5) Fixed a
  shutdown SIGSEGV at its root: `tira_dela_cola()` now returns early when
  the yuno is not playing (`gobj_pause()` clears the playing flag before
  `mt_pause()`/`close_queues()`), so a deferred EV_ON_CLOSE delivered
  during shutdown no longer touches the closed queue — no defensive NULL
  check needed.

- **fix(c_tcp): retry with backoff after a failed reconnect in the
  inactivity model.** `set_disconnected()` always cleared the timer in the
  `timeout_inactivity` model — correct for a deliberate idle-close, but it
  also stalled a failed on-demand reconnect (no retry, and no
  EV_DISCONNECTED for a never-connected socket). Now only an idle-close
  (`ac_timeout_inactivity` sets `idle_closed`) skips the retry; a connect
  failure / dropped link schedules the `timeout_between_connections`
  backoff and retries via `EV_TIMEOUT -> ac_connect`, like the classic
  model. This is the layer that owns reconnection/backoff (the emailsender
  rework relies on it).

- **fix(c_tcp): keep the pending tx queue across a FAILED reconnect in the
  inactivity model.** `set_disconnected()` flushed `dl_tx` on every
  disconnect, so bytes queued while disconnected (to be sent on the
  on-demand reconnect) were lost if the connect failed before succeeding —
  the message vanished silently and was never delivered. Now the queue is
  kept when the connection was NEVER established (`inform_disconnection`
  still FALSE) in the `timeout_inactivity` model on a running gobj, and
  `start_pending_writes()` flushes it once a retry connects. An established
  connection still flushes (its byte stream is broken); `mt_stop()` still
  flushes unconditionally (no leak on stop). New regression test
  `tests/c/c_tcp_inactivity` test4 (queue while the server is down → fail
  retries → server up → echo confirms delivery); it fails against the
  pre-fix code (no echo, FIFO timeout).

- **refactor(c_tcp): `timeout_inactivity` / `timeout_between_connections`
  / `rx_buffer_size` are deployment config (`SDF_RD`), not runtime
  knobs** — dropped `SDF_WR` (and the misleading `SDF_PERSIST` on the
  two timeouts); widened `priv->timeout_inactivity` to `json_int_t`.

- **fix(yev_loop): retry the static resolver's UDP `recv()` on `EINTR`,
  and log `gai_strerror(ret)` not `strerror(errno)`.** A signal
  interrupting the blocking DNS `recv()` made `yuneta_getaddrinfo()`
  fail spuriously (the logged "Interrupted system call" was a stale
  residual errno; getaddrinfo-family return an `EAI_*` code). Both
  `getaddrinfo() FAILED` sites now log the real `gai_*` cause.

- **fix(ytls): send SNI in OpenSSL client handshakes.** The
  OpenSSL backend never set the TLS `server_name` extension —
  the code was a `// TODO SSL_set_tlsext_host_name` stub — so
  client ClientHellos went out without SNI. Virtual-hosted TLS
  endpoints behind a CDN/WAF (e.g. an Imperva Incapsula front
  end) reject SNI-less handshakes with HTTP 403. The mbedTLS
  backend already set SNI and `c_tcp` already supplies
  `ssl_server_name`; only the OpenSSL path dropped it. Now
  stores `ssl_server_name` in `init()` and calls
  `SSL_set_tlsext_host_name()` per-connection in
  `new_secure_filter()` for client sockets. Server side is
  unaffected (no servername callback registered, so incoming
  SNI is ignored). Verified end-to-end: 403 → 200 against an
  Imperva-fronted HTTPS API.

- **fix(timeranger2): fire key-delete callbacks for `rt_by_disk`
  followers.** `fire_key_deleted_locally()` skipped every entry
  with an `fs_event_client` (i.e. every `rt_by_disk` follower),
  assuming the `FS_SUBDIR_DELETED` inotify branch fired their
  `key_deleted_callback`. But that branch's only firing
  mechanism *was* `fire_key_deleted_locally()`, which skipped
  them — so a `rt_by_disk` follower's key-delete callback never
  fired on a `tranger2_delete_key()`. The follower's in-memory
  state only reconciled on restart (LOADING reload from
  `keys/`); live deletes were silently dropped for every
  fs-watcher follower framework-wide. Split the fan-out by
  transport via a new `fs_followers` flag: the master in-process
  path fires only non-watcher subscribers (rt_mem
  lists/iterators); the `FS_SUBDIR_DELETED` inotify branch fires
  only the `rt_disk` followers (the inotify event IS their
  signal). Each subscriber now fires exactly once; also removes
  a latent same-process double-fire of non-watcher subscribers.
  Verified: a master `tranger2_delete_key` now drops the key
  from a separate-process follower's in-memory cache live, no
  restart.

- **fix(yuno_agent): promote highest `yuno_release` to primary on
  `restart_nodes`.** The treedb primary for a yuno-id is the
  highest-ROWID record, not the highest `yuno_release`:
  lifecycle writes (kill/run/snap) append records for whatever
  release is *active*, so after `install-binary` +
  `find-new-yunos` an older release could stay primary and
  `deactivate-snap` relaunched it instead of the new version
  (the long-standing "force volatil" TODO; a `shoot-snap`
  between `find-new-yunos` and `deactivate-snap` reliably
  triggered it). New `promote_highest_release_yunos()` runs in
  `restart_nodes()` BEFORE the treedb reload: for each id whose
  highest non-disabled `yuno_release` is newer than the current
  primary, it re-appends that release so it becomes the highest
  rowid; the reload then makes it primary and
  `run_enabled_yunos()` launches it. An append does NOT move the
  in-memory primary index — only the reload rebuilds it — so the
  promote must precede the `gobj_stop/start`. Version order via
  the existing `get_n_v()`. (`volatil` itself was already
  honored — `mt_update_node` routes volatil updates to
  `set_volatil_values`, in-memory only — so the culprit was the
  non-volatil lifecycle/snap writes, not the run-update.)
  Verified: a multi-yuno realm upgraded to a new release with a
  single `deactivate-snap`.

- **fix(yev_loop): retry transient ENOMEM in
  `io_uring_queue_init_params`**. A synchronised restart of
  many yunos (e.g. an agent `deactivate-snap` on a node with
  10+ yunos) used to drop 1-3 SIGABRT cores per yuno in
  `/var/crash`, even though every yuno eventually came up
  after the `ydaemon` watcher relaunched it. Root cause was
  `yev_loop_create` aborting via `LOG_OPT_ABORT` on a
  transient `-ENOMEM` from io_uring init: rings consume
  pinned kernel memory (RLIMIT_MEMLOCK / vm.max_user_locks)
  and a simultaneous restart of N yunos saturates that
  budget for a few ms while the previous rings' pages are
  released. Forensic evidence: 12 cores at 13:23 today with
  identical bt bottoming at `yev_loop.c:184`, all `err=-12`
  with `entries=32768`. Fix wraps the init call in a
  5-iteration exponential-backoff retry (100/200/400/800/1600
  ms ≈ 3 s) for `ENOMEM`/`EAGAIN` only; non-transient errors
  (EINVAL, ENOSYS, EPERM…) fall through to the original
  abort path unchanged. Each retry logs a warning so an
  operator can see the pressure event without it being
  silent. Local stress test (3 consecutive `deactivate-snap`
  cycles = 48 yuno restarts) generated 0 cores.

- **fix(ycommand): keep stdin-pipe queue draining when a
  command returns -1**. The long-lived stdin-pipe mode added
  in 7.4.3 inherited the ybatch convention from `-c` / `-i` /
  file-fed batches: a `-1` result with no leading `-` on the
  command drops the rest of the queue. That convention is
  hostile to stdin-pipe deploys — the operator has already
  piped every line in, and one common non-fatal `-1` (e.g.
  `install-binary` returning "Binary already exists" for a
  slot that's already filled) silently swallows the rest.
  Surfaced on the 7.4.3 wattyzer deploy: binaries got
  registered, then `find-new-yunos` + `deactivate-snap`
  vanished, leaving yunos on the old release until the
  trailing commands were re-run by hand. Fix: in
  `stdin_pipe_mode` every command behaves as `ignore-fail`
  (no queue clear on error). Explicit `-` prefix path stays.
  Repro matrix (4 install-binary in pipe, slot pre-filled):
  before fix 3/4 responses, after fix 4/4. The earlier
  "WS frame interleaving" hypothesis logged in TODO.md was
  ruled out (kept as a post-mortem trail).

- **fix(emailsender): retry queued emails instead of
  dead-lettering on the first failure, and persist the body**.
  Any send failure (SMTP server down, wrong URL, rejected
  AUTH, or simply the SMTP child still connecting when the
  dequeue timer fired) used to move the email straight to the
  `emails_failed` dead-letter queue and unload it — `max_retries`
  was declared but never used and nothing drains the failed
  queue, so one transient hiccup shelved the message forever.
  Now the message stays at the head of `emails_queue` and is
  only dispatched while the SMTP session is connected and
  authenticated (a momentary outage just waits and retries on
  reconnect); transient failures are retried up to `max_retries`
  total attempts before being dead-lettered. The body is now
  persisted as a string in the queue — it was carried as a
  transient gbuffer pointer that the dequeued kw's auto-decref
  freed after the first attempt, so retries (and any yuno
  restart) lost the body. Also split RCPT recipients on `;` as
  well as `,` (an Outlook-style list or a stray trailing `;`,
  e.g. logcenter's summary `to`, was rejected by the server as
  `501 Invalid TO`). Deployed and validated live on
  `emailsender^artgins`.

- **feat(emu_device): implement the frame-emission path on
  timeranger2**. The device-gate emulator was a scaffold: its
  replay was written against the removed timeranger v1 API and
  its `__output_side__` had no TCP connex (the v6 Connex/Tcp0
  globals were dead). Ported to v7 — the output side is built
  in code (`C_IOGATE > C_CHANNEL > C_PROT_RAW > C_TCP` to `url`,
  like `sgateway`); `mt_play` loads matching `frame64` records
  via `tranger2_open_list`, and on connect it sends the
  `leading` frame then `window` frames every `interval` ms.
  Compile-verified only; end-to-end runtime validation (needs a
  `frame64` topic + a TCP sink) is tracked in `TODO.md`.

v7.4.3 -- 27/May/2026

- **feat(emailsender)!: drop libcurl, native SMTP over ytls**.
  `emailsender` was the only yuno that linked libcurl, which
  dragged OpenSSL/libssh2/c-ares/libidn2/libpsl/libnghttp2/3/
  zlib/brotli into its runtime graph. The dev-host glibc kept
  bumping while production stayed on older versions (e.g. 2.36
  on `app.wattyzer.com`), so emailsender was the one yuno where
  every upgrade needed a build environment matched to the
  target — and it was deliberately skipped from the 7.4.1
  deploy bundle for that reason. Three new building blocks
  land in this release: (1) `C_SMTP_SESSION` (yunos/c/
  emailsender/src/c_smtp_session.{c,h}) — a CHILD-pattern
  protocol gclass that owns a `C_TCP` bottom and walks the
  RFC 5321 submission FSM (banner → EHLO → AUTH PLAIN →
  MAIL FROM → RCPT TO → DATA → \r\n.\r\n → QUIT) with
  multi-recipient support, RFC 5321 §4.5.2 dot-stuffing and
  a best-effort QUIT on `mt_stop`. Uses `istream_read_until_
  delimiter("\r\n", 2, EV_RX_LINE)` so raw bytes from C_TCP
  (`EV_RX_DATA`) and parsed lines (`EV_RX_LINE`) flow through
  distinct actions and never feed back into the istream.
  (2) `mime_encoder.{c,h}` — pure helpers, no gclass; builds a
  complete RFC 5322 message with optional single attachment
  (`multipart/mixed`) or inline-image attachment with
  Content-ID (`multipart/related`), base64-wrapped at 76
  chars per RFC 2045 §6.8, and RFC 2047 base64 encoded-words
  for non-ASCII Subject / From display-name. (3) Cutover in
  `c_emailsender.c`: `priv->curl` → `priv->smtp` as a
  pure_child created with `{url, username, password,
  helo_name=gethostname()}`; the synchronous `gobj_send_event
  (priv->curl, EV_CURL_COMMAND, …)` + immediate
  `process_curl_response` flow becomes async — `ac_smtp_
  command` MIME-encodes, sends `EV_SEND_MESSAGE` to the smtp
  child and stays in `ST_WAIT_RESPONSE`; the response arrives
  later via the new `ac_on_message` handler. Kernel-side
  precursor: `_yev_protocol_fill_hints()` in `kernel/c/
  yev_loop/src/yev_loop.c` learns the `smtps` schema (port
  465, marked `secure=TRUE`) alongside the existing
  `mqtts`/`wss` — strictly additive. Together this removes
  `libcurl4-openssl-dev` from `docs/doc.yuneta.io/
  installation.md`, drops `find_package(CURL REQUIRED)` and
  `${CURL_LIBRARIES}` from the yuno's CMakeLists, deletes
  `c_curl.{c,h}` outright, and brings the emailsender binary
  down to `ldd` reporting only `libgcc_s` + `libc` (vs the
  previous ~12 shared libs). `emailsender^artgins` is already
  pointed at `smtps://ssl0.ovh.net:465` in all three realms +
  the staging batch, so the cutover is a no-config-change
  deploy. **Breaking change for callers building EV_SEND_EMAIL
  kw manually**: the libcurl-era attrs `strict_tls` and
  `auto_inline_images` are ignored (TLS is now decided by the
  URL schema; auto-inline-image HTML rewriting was a libcurl
  `curl_mime_*` feature not reimplemented). The `cmd_send_
  email` command schema is unchanged; existing callers (the
  whole estadodelaire batch + every realm config) keep
  working without edits.

- **feat(ycommand): long-lived stdin-pipe session keeps OAuth2
  auth open across many commands**. Until now `ycommand` had
  three input shapes: `-c CMD` (one command, exit), `-i`
  (interactive editline over a raw TTY), and an undocumented
  synchronous pipe path inside `ac_on_open` that did
  `while(fgets(line, stdin))` — fine for pre-buffered batches
  but it blocked the yev_loop between lines, so any
  programmatic driver that wanted to send a command, read its
  response, then send another would hang. `-i` was also
  unusable for non-TTY drivers (e.g. an AI coding agent running
  `ycommand` via Bash) because `tty_keyboard_init`
  unconditionally calls `enableRawMode`, which fails with
  "NOT a TTY" on a piped fd. Net effect: every remote command
  paid the full OAuth2 ROPC round-trip (~200-400 ms against
  Keycloak) even when the caller had ten queued up. New
  behavior, no new flag: when stdin is not a TTY and neither
  `-c` nor `-i` was supplied, ycommand sets up an io_uring read
  event on `dup(STDIN_FILENO)` (yev_loop refuses fd<=0, so we
  dup) and drives lines through the existing
  `split_commands_into_queue` + `run_next_pending` machinery.
  The process stays alive between commands, EOF triggers an
  orderly shutdown via the existing `set_timeout(timer,
  wait*1000)` → `ac_timeout` → `exit()` path, and a
  `priv->cmd_in_flight` gate serialises async dispatches so a
  stdin line arriving mid-flight enqueues instead of racing.
  Auth happens once; the rest of the session is free. Tested
  against local `ws://127.0.0.1:1991` (4-line batches and
  delayed sequences with 3 s gaps between lines) and against
  `wss://app.wattyzer.com:1993` + OAuth2 (three commands with
  2 s gaps, single ROPC). Backwards-compatible: the pre-existing
  `-c` and `-i` paths are untouched, the pipe-mode trigger is a
  strict superset of the previous synchronous fgets behaviour,
  and `echo cmd | ycommand` keeps producing the same output as
  before (just via an event-driven reader). `c_ycommand.c`
  grew +220/-25; no changes elsewhere.

- **docs(philosophy): add "The Typed-Graph Model" chapter**.
  New page under `philosophy/` slotted between
  [Design Principles](doc.yuneta.io/philosophy/design_principles.md)
  and [Domain Model](doc.yuneta.io/philosophy/domain_model.md),
  articulating the conceptual claim the framework rests on: data
  and behavior are two views of the same typed graph
  (`topic`↔`gclass`, `node`↔`gobj`, `hook`/`fkey`↔
  subscription/`bottom_gobj`, with `sdata_desc_t` describing
  schemas on both planes). Sections cover: the unit is the
  typed binding, not just the node; the two-plane primitive
  table; what kinds of organisation the model can express
  (hierarchies, matrix, workflows, communication topologies,
  versioned-over-time); what does not fit cleanly (schemaless
  iteration, OLAP, eventually-consistent distributed state,
  truly opaque payloads); the implicit axiom; the payoffs at
  scale; and the empirical justification from 15 + years of
  v2/v6 production. Cross-links added in `philosophy/`
  neighbours and reverse-links from `yunos/c/yuno_agent/`'s
  `ENTRY_POINT.md` (See also), `GOBJ.md` (Conceptual frame
  callout: behavior plane) and `YUNO_TREEDB.md` (Conceptual
  frame callout: information plane) so a reader landing in the
  technical chapters can step up one level on demand. Build is
  warning-free; the new chapter appears in the TOC under
  Philosophy.

- **fix(install-binary): surface the real cause in error
  response**. `cmd_install_binary` built its failure comment as
  `json_sprintf("Cannot create binary: %s",
  gobj_log_last_message())`, which produced *"Cannot create
  binary: "* (empty cause + trailing space) whenever the
  underlying `treedb_create_node` returned NULL because the
  `(id, pkey2=version)` combination already existed — that path
  logs *"Node already exists"* via `gobj_log_warning`, and
  `gobj_log_warning` does not populate `last_message` (only
  `LOG_ERR` and above do). After the per-command reset in
  `command_parser` (7.4.1, `b1abd7f69`), the buffer is `""` by
  then. Two-layer fix, same shape as the snap commands in 7.4.1:
  `treedb_create_node` now calls `gobj_log_set_last_message()`
  alongside the warning so the cause (*"Node already exists in
  '<topic>': id='<id>'"*) reaches every caller that pipes
  `gobj_log_last_message()` into the response (≈13 callers in
  `c_node.c` benefit alongside `cmd_install_binary`);
  `cmd_install_binary` reads `last_msg` once and falls back to
  `"(see log)"` if it's empty, so the response is always
  informative regardless of whether layer-1 was reached. Drive-by:
  removed the stale `// TODO check tranger2_write_user_flag`
  marker above `treedb_shoot_snap` — the function was completed
  in 7.4.0/7.4.1 (`4c89e4b2c` + `46f8f0434`) and the audit
  confirmed no remaining wiring gap.

v7.4.1 -- 27/May/2026

- **fix(command_parser): stop misleading stale strerror in
  command responses**. Many `cmd_*` in `c_node.c` build their
  failure comment as `json_string(gobj_log_last_message())`,
  but `gobj_log_set_last_message()` is only called by
  `gobj_log_*()` with priority `<= LOG_ERR`. If the failure
  path logs at `LOG_INFO` (or doesn't log at all),
  `last_message` keeps whatever it had from the previous
  `LOG_ERR` — frequently `strerror(errno)` of an earlier TCP
  disconnect ("Connection reset by peer"). The response was
  being delivered correctly with that strerror as the comment;
  ycommand rendered it verbatim and it looked indistinguishable
  from a real network error, sending operators down a wild
  diagnostic chase. Two-part fix at the kernel command
  boundary in `kernel/c/gobj-c/src/command_parser.c`: (1)
  `command_parser()` resets `last_message` to `""` at entry,
  so every command dispatch starts with a clean slate;
  (2) `build_command_response()` substitutes `"(see log)"`
  when the response is a failure (`result != 0`) and the
  comment is an empty string, so callers that use the bare
  `json_string(gobj_log_last_message())` idiom produce a
  useful placeholder instead of `ERROR -1: `. Success
  responses keep their empty comment (cmd_topics etc. return
  data without a comment by design). Documented the new
  semantics in
  `docs/doc.yuneta.io/api/logging/log.md`
  (`gobj_log_last_message` + `gobj_log_set_last_message`).
- **fix(agent): `list-binaries` enumerates every
  `(role, version)` instance**. `cmd_list_binaries` called
  `gobj_list_nodes("binaries", ...)`, which only returns the
  in-memory primary per id (role). After an `install-binary`
  that added a second version under the same role, the new
  instance was invisible until a `deactivate-snap` rebuilt
  the primary index — and even then only the most-recent
  version survived. The doc claimed *"returns all rows"* but
  the call had never matched that promise. Switched to
  `gobj_list_instances("binaries", "", ...)`: the topic has
  `pkey2=version`, so the instances iterator returns one row
  per `(role, version)` and multi-version installs are
  visible from the moment `install-binary` appends the
  record. Validated with two coexisting `emailsender`
  versions (7.4.1 + 7.4.2): `list-binaries` now returns four
  rows instead of three. `list-binaries-instances` stays as
  the explicit "instances" alias. `YUNO_LIFECYCLE.md` table
  updated to match.
- **fix(snap): implement `snap-content` + recover error
  responses to ycommand**. (a) `cmd_snap_content` in
  `c_node.c` was a literal `"TODO"` stub. Now walks the
  requested topic with `tranger2_open_list` filtered by
  `user_flag=snap_id` and a `load_record_callback` that
  drains matching records into a `json_array` carried via
  the rt's `extra` (merged into the rt object by
  `json_object_update_missing_new`, so the callback reads
  `list->snap_data`, not `list->extra->snap_data`).
  Validates `topic_name` and `snap_id` (1..65534, matching
  the `uint16_t` md2 `user_flag` range), returns the schema
  + the array and a `(count)` comment. (b) Error responses
  from `shoot-snap` / `activate-snap` / `deactivate-snap`
  were arriving at ycommand as `"Connection reset by peer"`
  instead of the real cause. Root cause:
  `json_string(gobj_log_last_message())` produced an empty
  JSON string whenever the kernel function logged with
  `gobj_log_info` (which doesn't populate `last_message`),
  and the buffer still held the strerror of a prior
  disconnect. Two-layer fix: `treedb_shoot_snap` /
  `treedb_activate_snap` explicitly call
  `gobj_log_set_last_message()` in their "already exists"
  and "not found" paths; `cmd_shoot_snap` /
  `cmd_activate_snap` / `cmd_deactivate_snap` build the
  error comment with
  `json_sprintf("Cannot ... '%s': %s", name, empty_string(last)?"(see log)":last)`
  so the comment is never empty even if some future caller
  forgets the layer-1 update. Note: there are ~13 other
  `cmd_*` in `c_node.c` with the same
  `json_string(gobj_log_last_message())` pattern — same trap
  — covered by the systemic `command_parser` reset documented
  in the entry above.
- **fix(snap): preserve previous snap's tag + harden
  `run_yuno` launcher**. (a) `treedb_shoot_snap` stamped
  `user_flag` IN PLACE on every primary record. A second
  `shoot-snap` over a record that hadn't changed since the
  first snap therefore overwrote the older snap's tag,
  making that record unreachable from
  `activate-snap(older)`. Fix: if the primary record already
  carries a tag from a different snap, append a CLONE via
  `tranger2_append_record(user_flag=new)` so the older
  record keeps its tag. Untagged primaries (and same-snap
  re-stamps) still take the in-place path — no extra
  storage. The in-memory `__md_treedb__` is intentionally
  left untouched on the clone branch so subsequent
  `treedb_save_node()` appends keep using the original base
  `rowid`. Validated end-to-end: shoot A → 13 records carry
  `uflag=1`; shoot B (no intermediate change) → still 13
  records carry `uflag=1` + 13 clones carry `uflag=2`;
  `activate-snap A` and `activate-snap B` both restore all
  yunos. (b) `build_yuno_running_script` in `c_agent.c` took
  an uninitialised `char bfbinary[]` from the caller's stack
  and could early-return `0` (silently) on a missing realm
  or binary. All three callers ignored the return value,
  then ran or serialised whatever garbage was on the stack —
  hence the corrupted `/yuneta/bin/<role>^<name>.sh`
  launchers (~21-27 bytes of stack noise) that
  `activate-snap` produced when the binary record didn't come
  back. Fix: zero-init `bfbinary` at function entry, log the
  two early-return sites (no silent errors), and have every
  caller check the return value and respond with an error
  instead of piping uninitialised memory into
  `run_process2()` or a JSON reply. Treedb `treedb_shoot_snap`
  doc page updated to describe the new clone-vs-stamp
  behaviour.
- **fix(timeranger2): silence two `-W` warnings without
  losing errors**. (a) `treedb_shoot_snap` in
  `kernel/c/timeranger2/src/tr_treedb.c` now returns the
  accumulated `ret` so `tranger2_write_user_flag` failures
  across topics surface to callers instead of being silently
  dropped. (b) `mirror_key_delete_to_disks` in
  `timeranger2.c` uses `build_path()` to assemble the
  `disks/<rt_id>/<key>` path; `build_path` already syslogs
  `LOG_CRIT` on overflow, so no silent skip on truncation
  (closes a `-Wformat-truncation` warning without
  introducing a silent early-out).
- **docs(api/treedb): document the real snap semantics**.
  The `treedb_shoot_snap` / `treedb_activate_snap` API pages
  described the surface only — name, parameters, return — and
  missed the parts that determine whether snaps actually work
  for the caller: `treedb_shoot_snap` tags the live `.md2`
  record's `user_flag` in place via
  `tranger2_write_user_flag` (so rowid order is preserved
  and re-shoots overwrite prior tags on the same record);
  `treedb_activate_snap("__clear__")` is the deactivate
  path; the active/inactive toggle only flips a flag, and
  the new primary visibility materialises on the **next**
  `treedb_open_db()` — not on the call itself. Updated
  `docs/doc.yuneta.io/api/timeranger2/treedb.md` §§
  `treedb_shoot_snap` + `treedb_activate_snap` with the
  reload semantics + the in-place tag mechanic + the
  16-bit snap-id ceiling. Companion to the `treedb_shoot_snap`
  completion shipped in the same release.
- **feat(tr_treedb): complete `treedb_shoot_snap` so
  `activate-snap <name>` rolls back primaries correctly**.
  The TODO at the heart of `treedb_shoot_snap` was a dead
  branch: it walked the primary index of every topic but
  the actual `tranger2_write_user_flag` call was commented
  out, so snaps only ever created an entry in `__snaps__`
  and never tagged any record. The agent's `activate-snap
  <name>` path queried `snap_tag` correctly on reload (see
  `treedb_open_db` line 1299 — the user_flag filter is
  enforced), but with no record carrying the tag the load
  returned an empty primary index, and the rollback silently
  did nothing — the test suite never caught it because there
  was none. Wired the tag write in-place via
  `tranger2_write_user_flag(tranger, topic_name, key, t,
  i_rowid, user_flag)` so the existing record gets stamped
  without inflating the `.md2` (using `treedb_save_node`
  would create a new instance at the highest rowid and
  then steal "latest" after `deactivate-snap`, masking the
  newer records the user actually wants live). Also tightened
  the snap-id range check from 32-bit (`0xFFFFFFFF`) to 16-bit
  (`0xFFFF`) since `user_flag` is `uint16_t` end-to-end. New
  regression: `tests/c/tr_treedb_snap` walks 9 phases
  modelling the agent's upgrade lifecycle (seed v1 → add v2
  → shoot snap_v1 → deactivate → reload picks v2 → add v3 →
  deactivate → reload picks v3 → shoot snap_v3 → activate
  snap_v1 → reload rolls back to v1 → activate snap_v3 →
  reload to v3 → deactivate → stays at v3) on two topics
  (`binaries` + `yunos`) keyed exactly like the agent's
  `binaries` / `configurations` / `yunos`. `tr_treedb` and
  `tr_treedb_delete_instance` rerun green against the patched
  library — no regressions.
- **docs(yuno_agent): document the version-bump upgrade flow**.
  `kill-yuno` + `run-yuno` does not pick up a new release —
  `cmd_run_yuno` walks the `yunos` topic primary index, which
  keeps pointing at the older `pkey2` (`yuno_release`) after
  `find-new-yunos create=1` appends the new row. The fix is
  always `install-binary` → `find-new-yunos create=1` →
  `deactivate-snap`. `deactivate-snap` with no args (and no
  active snap) is the only supported way to trigger
  `restart_nodes()` (`c_agent.c:8816`), which SIGKILLs every
  running yuno, `gobj_stop/start`s the treedb resource so the
  primary index is rebuilt from disk with the newest `pkey2`
  first, then runs every must-play yuno. Equivalent to
  `yshutdown` + `restart-yuneta` at the agent-process level,
  but without restarting the daemon. Added as `YUNO_LIFECYCLE.md`
  §6.5 + §6.6 (rollback via `shoot-snap` / `activate-snap`) and
  refreshed `CLAUDE.md` §3 with the same-version vs version-bump
  split. Verified live with `gate_pvpc 1.3.1.0 → 1.3.1.1`
  on the local agent.

v7.4.0 -- 26/May/2026

- **chore(lib-yui)!: declarative shell stack removed —
  `@yuneta/lib-yui` jumps to 8.0.0**. The new declarative
  shell (`C_YUI_SHELL`, `C_YUI_NAV`, `C_YUI_PAGER`,
  `C_YUI_WIZARD`, `shell_modals` and every `shell_*_helpers`
  module + the full Playwright e2e suite and the `test-app/`
  vite project) was already being maintained from the
  wattyzer-vendored copy at `wattyzer/gui/src/lib-yui`. The
  kernel copy was just dead weight in the bundle consumed
  by estadodelaire (legacy apps that only use
  `C_YUI_MAIN` + `WINDOW` + `TABS` + routing) and the two
  copies had drifted enough to make every fold-back a
  conflict. Verified by grep that neither legacy consumer
  imports any shell symbol before deleting. `dist/lib-yui.es.js`
  is now 3.4 MB / gzip 706 KB (~25% lighter). Migration: if
  you need the declarative shell, the canonical copy is
  `wattyzer/gui/src/lib-yui/` (private repo) as of
  2026-05-15. The yuno-skeleton `js_gui` scaffold that
  referenced `register_c_yui_shell` was dropped here too;
  the replacement scaffold lives in
  `wattyzer/templates/js_gui/`.
- **chore(ext-libs): three security bumps (v1.12 → v1.13 →
  v1.14)**. v1.12: nginx 1.28.3 → 1.30.1 (CVE-2026-42945),
  openresty → 1.29.2.4, openssl 3.6.1 → 3.6.2. v1.13: nginx
  → 1.30.2 (CVE-2026-9256, buffer overflow in
  `ngx_http_rewrite_module`). v1.14: openresty → 1.29.2.5
  (backports the CVE-2026-9256 patch into the
  openresty-bundled nginx + a `proxy_protocol v2`
  over-read fix). All three are pin-only — nginx and
  openresty are separate dynamically-linked binaries (see
  `configure-libs.sh` v1.10), so no yuneta consumer /
  header / CMake change rides along. OpenSSL deliberately
  held on the 3.6 LTS series; the 4.0 jump (non-LTS,
  EOL 2027-05, drops engines / legacy init) is tracked
  separately.
- **refactor(tranger2): rename `tranger2_delete_record` →
  `tranger2_delete_key`**. Locks the vocabulary
  timeranger2 was using loosely: *record* = a primary key
  (whole `keys/<key>/` directory, deleted via
  `tranger2_delete_key`); *instance* = one row of that
  key's `.md2` index, addressed by
  `(key, __t__, rowid)`. The legacy name is kept as a
  source-level alias
  (`#define tranger2_delete_record tranger2_delete_key`),
  so external callers keep compiling unchanged. In-tree
  caller (`treedb_delete_node`) updated; README, the
  timeranger2 API page (with the old MyST anchor preserved
  so external links to `(tranger2_delete_record)=` keep
  resolving) and the appendix index follow the rename. A
  subsequent commit dropped "soft" from the delete-instance
  vocabulary: granularity, not reversibility — both
  deletes are irrecoverable.
- **feat(tranger2): `tranger2_delete_instance()` — per-row
  tombstone**. Mutates one row of the `.md2` index in place via
  `sf_deleted_instance = 0x0400` (reinstated in `system_flag2_t`
  on the inherited side of the mask, so `rt_by_disk` followers
  see the same tombstone as the master). Optional `zero_payload`
  overwrites the matching `__size__` bytes at `__offset__` in the
  data `.json` for sensitive-data wipes. Three read sites honour
  the bit and skip dead rows: `tranger2_open_iterator` history
  loop, `tranger2_iterator_get_page`, and
  `publish_new_rt_disk_records`. Treedb is downstream and
  inherits the skip with no `tr_treedb` change. Master-only.
  Second delete of the same row is a silent no-op. `rowid`s do
  NOT renumber; `iterator_size` / `total_rows` keep counting
  slots, not live rows. `tranger2_read_record_content` and
  `tranger2_read_user_flag` still serve dead rows when the caller
  addresses them directly (audit / wipe-verification tooling).
  Coverage: `tests/c/timeranger2/test_delete_instance.c` (5
  sub-cases).
- **feat(tranger2): `tranger2_delete_key()` propagates to
  subscribers**. Pre-2026-05-26 the function `rmrdir`'d
  `keys/<key>/` and cleared the in-memory rollup cache, but
  never notified subscribers — `rt_mem` listeners kept stale
  references and `rt_by_disk` followers in other processes kept
  their cached view alive. Now: (1) `topic/disks/<rt_id>/<key>/`
  subdirectories are removed BEFORE the live `keys/<key>/`, so
  followers catch the deletion on the standard inotify channel
  (`FS_SUBDIR_DELETED_TYPE`, the v7 TODO branch that had been
  logging "NOT processed" since inception is now wired); (2)
  in-process subscribers receive a registered
  `tranger2_key_deleted_callback_t` via the new
  `tranger2_set_rt_key_deleted_callback()` setter. Additive
  typedef and setter — no breaking signature change to
  `open_rt_mem` / `open_rt_disk` / `open_iterator`. Coverage:
  `tests/c/timeranger2/test_delete_key_propagation.c` (5
  sub-cases). Wattyzer's "tombstone-then-delete" workaround in
  `db_history_wz` becomes redundant after one production cycle
  of coexistence — cleanup planned in the wattyzer repo, not
  here.
- **fix(tr_treedb): repair `treedb_delete_instance`
  (pkey2-index cleanup only)**. The function was a dead
  copy-paste of `treedb_delete_node` with the real work
  fenced behind `if(0) { ... tranger2_delete_instance(...) }`
  and an `else` returning -1 with *"Cannot delete node"*. The
  dead branch was also wrong — it would have wiped the
  underlying `.md2` row while the primary index and the
  other `pkey2_*` indexes still referenced it (state
  corruption). The only in-tree caller
  (`c_node.c::ac_delete_node`) was getting -1 on every call
  without acting on the return, so the breakage was silent.
  Rewritten to do what the function name promises: drop the
  in-memory entry for THIS pkey2 via `delete_secondary_node`,
  fire `EV_TREEDB_NODE_DELETED`, preserve the JSON_INCREF /
  DECREF pattern. The whole-node wipe stays the job of
  `treedb_delete_node` → `tranger2_delete_key`. Contract
  spelled out in the `.c` and `.h` docstrings.
- **fix(ytls/openssl): ship the full certificate chain**.
  `build_ssl_ctx()` was loading the server certificate via
  `SSL_CTX_use_certificate_file()`, which only parses the first
  cert of a PEM bundle.  With a Let's Encrypt fullchain.pem on
  disk, that meant the listener served only the leaf — browsers
  hid the issue via AIA-fetch / cached intermediates, but
  strict-TLS clients (e.g. Node's native `fetch`, used by the
  Playwright QA driver against the public URL) failed chain
  verification.  Switched to
  `SSL_CTX_use_certificate_chain_file()` (chain-aware, PEM-only
  — no `SSL_FILETYPE_PEM` arg).  The mbedTLS backend
  (`mbedtls_x509_crt_parse_file`) was always chain-aware so it
  was not affected.  Every yuno that exposes a TLS server with
  the OpenSSL backend needs a relink + redeploy to pick up the
  fix; for binaries shared by several live yunos (e.g.
  `auth_bff` 1802+1804) the atomic `mv old old.bak; cp new old`
  pattern avoids the `ETXTBSY` that breaks `update-binary`.
- **chore(ytls, c_authz): drop OpenSSL legacy init/cleanup
  calls** (OpenSSL 4.0 prep). Removed four
  deprecated-since-1.1.0 calls that were no-ops on the 3.x
  series and disappear in 4.0: `SSL_library_init()` +
  `OpenSSL_add_all_algorithms()` (with the redundant
  `__initialized__` guard) in `ytls/openssl.c` init,
  `EVP_cleanup()` in cleanup, and
  `OpenSSL_add_all_digests()` (with its
  `CONFIG_HAVE_OPENSSL` wrapper) in `c_authz.c`. OpenSSL
  ≥ 1.1.0 auto-initialises on first use and cleans up via
  `atexit`. The `OPENSSL_API_COMPAT 30100` define already
  gates the rest of the 1.1.x compat surface; the yuneta
  source is now 4.0-clean (the jump itself stays deferred).
- **fix(c_prot_tcp4h, c_prot_mqtt): guard state reset
  against in-publish disconnect cascade**. Under io_uring,
  publishing `EV_ON_MESSAGE` is synchronous and can trigger
  a full disconnect cascade upstream (authz NAK in
  `C_IEVENT_CLI` → `EV_DROP` → `C_TCP` ac_drop →
  `try_to_stop_yevents` → `set_disconnected` publishes
  `EV_DISCONNECTED` → the protocol gclass moves to
  `ST_DISCONNECTED`). The caller of `frame_completed`
  then unconditionally reset the FSM back to
  `ST_WAIT_FRAME_HEADER` / `ST_CONNECTED`, leaving the
  protocol "connected" without an underlying TCP. Symptom:
  *"Event NOT DEFINED in state: EV_CONNECTED in
  C_PROT_TCP4H@ST_WAIT_FRAME_HEADER"* alternating with
  broken-pipe / local-dropping cycles. Guard
  (`state != ST_DISCONNECTED`) added — same form already
  present in `c_websocket.c` and `c_prot_mqtt2.c`. The
  twin guard initially added to `c_prot_modbus_m` was
  reverted in a follow-up: the modbus-master flow does
  not expose the same cascade (see `GOBJ.md §8.13`).
  Verified live on `app.wattyzer.com` across the
  controlcenter dial-out loop.
- **fix(gobj): `gobj_read_attrs` honours `mt_reading` via a new
  `item2json` helper**.  The bulk reader (behind `view-attrs`,
  introspection and `db_save_persistent_attrs`) was the only
  attribute path bypassing `mt_reading`, so `SDF_RSTATS` counters
  kept in `priv->X` read as zero through `view-attrs` even though
  `stats-yuno` (typed readers) saw the live value.  The helper
  dispatches by `DTP_*` and falls back to the stored value when
  `mt_reading` is absent or returns `!v.found`.  `gobj_read_attr`
  (single, borrowed-ref) is intentionally left alone: its
  "Return is NOT yours!" contract is incompatible with allocating
  a fresh `json_t` from a typed override.
- **fix(gobj-js): mirror C — `gobj_read_attrs` honours
  `mt_reading`**.  Same shape as the C kernel patch, simplified
  (dynamic types, no `DTP_*` switch): `undefined` falls back to
  the stored value.  Defensive — no JS gclass implements
  `mt_reading` today, so this only closes the symmetry.
- **fix(c_tcp): set `v.found = 1` for `cur_tx_queue` in
  `mt_reading`**.  Pre-existing one-liner; the branch updated
  `v.v.i` but forgot the discriminant, so the value was always
  shadowed by the stored zero.  Surfaces now that
  `gobj_read_attrs` consults `mt_reading`.
- **fix(lib-yui): normalize `navigator.language` before
  `Intl.DateTimeFormat`**. Playwright Firefox without locale
  config (and some embedded webviews) report
  `navigator.language` as the literal string `"undefined"`;
  passing that to `Intl.DateTimeFormat` throws `RangeError`
  and breaks SPA bootstrap. Guard with a string +
  `"undefined"` check, fall back to `undefined` so Intl
  resolves to the system locale. Fold-back of wattyzer
  `1bf08aa`.
- **feat(yuno_agent): `stats-yuno` defaults `service` to the
  matched `yuno_role`**. `cmd_stats_yuno` previously passed
  an empty `service` when the operator didn't spell it out,
  so the remote fell back to `priv->gobj_service` (the top
  `C_YUNO` instance) and returned only the few attrs declared
  on `c_yuno` — typically all zero, missing the real
  `SDF_RSTATS` counters that live on the citizen service
  (e.g. `C_AUTOMATIONS_WZ`'s `alarms_seen` /
  `tracks_seen` / `fires_seen` / `runs_done`). Convention is
  `gobj_create_default_service(yuno_role, GCLASS, ...)` so
  service name == yuno role; the operator now gets the real
  counters with the natural `stats-yuno yuno_role=X`
  invocation. Pass `service=__yuno__` to explicitly query the
  top `C_YUNO` attrs (previous default).
- **fix(tr_treedb): include the required-field name in error
  logs**. The three "Field required" `gobj_log_error` sites in
  `check_desc_field` / `normalize_node_field_value` /
  `convert_node2tranger` logged the same opaque message; now
  the field name is interpolated into `msg` so the offending
  column is visible at a glance without expanding the
  structured payload.
- **chore(yuno_agent): increase agent log file size**. Bumps
  the agent's own log rotation threshold in `yuno_agent` and
  `yuno_agent22` (two-line `main.c` change). Avoids
  tighter-than-needed rollovers under the trace volume the
  onboarding doc work surfaced.
- **note**: validated by relinking every yuno (15 binaries) and
  running the full `ctest` suite (93/93 passed, 436 s).  A
  project-wide `yunetas build` after a kernel-side change does
  pick up the relink correctly — the `rm <yuno_bin> &&
  make install` workaround is only needed when rebuilding a
  single yuno's `build/` directory in isolation.

v7.3.4 -- 16/May/2026

- **chore(release): corrective republish of `@yuneta/lib-yui`**.
  `@yuneta/lib-yui@7.3.3` was published to npm from a branch that
  had the 7.3.3 release prep (gobj-js dependency pin, CHANGELOG,
  version) but **not** the G6 v5 canvas panning fix (PR #115):
  the published 7.3.3 tarball is missing
  `src/g6_drag_canvas_touch.js` and the `autoResize:false` /
  `ensure_drag_canvas_patch` changes, so installing lib-yui from
  npm still had the broken touch/desktop panning. npm versions
  are immutable, so 7.3.4 republishes `@yuneta/lib-yui` from
  `main` with the complete set (#115 + #116). No source changes
  versus what `main` already contained at 7.3.3 — this is a
  packaging correction only.
- **chore(release): `@yuneta/gobj-js` 7.3.3 → 7.3.4 (lockstep)**.
  `@yuneta/gobj-js@7.3.3` on npm was already correct (it carries
  the `createElement2` nullish-`data-i18n` guard). It is bumped to
  7.3.4 with no functional change purely to keep the two JS
  packages in lockstep and avoid version-skew confusion; the
  `@yuneta/lib-yui` peer range moves to `^7.3.4`.
- **note**: deprecate the bad artifact —
  `npm deprecate "@yuneta/lib-yui@7.3.3" "incomplete: missing the
  G6 panning fix; use >=7.3.4"`.

v7.3.3 -- 16/May/2026

- **fix(lib-yui): G6 v5 canvas panning (touch broken, desktop
  desynced)** (PR #115).  G6 v5.1.0 `drag-canvas` derived the pan
  delta from `event.movement`, which `@antv/g` fills from native
  `PointerEvent.movementX/Y`: left at 0 for touch pointers on most
  mobile browsers (canvas barely panned) and skewed by OS pointer
  acceleration / `devicePixelRatio` on desktop (graph lagged the
  cursor).  New `g6_drag_canvas_touch.js` subclasses `DragCanvas`,
  reuses its clamp/cursor logic, and pans by the `event.viewport`
  delta — reliable on mouse and touch, correct for any canvas
  scale/zoom.  Registered once over the built-in `'drag-canvas'`
  id so every graph (gobj-tree, json-graph, treedb editor) is
  fixed without per-consumer changes.  `c_yui_gobj_tree_js.js`
  and `c_yui_json_graph.js` also stop fighting G6 `autoResize`
  (window-only in v5) and size the canvas to its content box via
  a self-contained `ResizeObserver`.

- **fix(gobj-js): `createElement2` no longer poisons `data-i18n`
  with `undefined`**.  A nullish `i18n` attribute (e.g. a field
  whose `header` is undefined) rendered the literal
  `data-i18n="undefined"` and suppressed translation, leaving
  form labels blank.  The attribute is now skipped when the value
  is `null`/`undefined`; an explicit empty string is still
  honoured.

- **fix(lib-yui): pin `@yuneta/gobj-js` dependency**.  The
  peer dependency was `"*"`, and a stale lockfile had frozen it
  to the ancient `@yuneta/gobj-js@0.3.0` from npm — so lib-yui
  (and downstream apps) silently built against 0.3.0 and updates
  had no effect.  Peer range is now `^7.3.3` and a
  `file:../gobj-js` devDependency makes local builds use the
  in-tree source.  Downstream apps (wattyzer, estadodelaire)
  must likewise repin and reinstall so they stop
  resolving 0.3.0.

- **feat(lib-yui): shell-mountable developer panel**.
  `build_dev_panel` plus a new `C_YUI_GOBJ_TREE_JS` hierarchical
  gobj-tree viewer; the dev panel is now a real window box (was a
  floating transparent overlay), with silent optional
  `__yui_main__` lookup and theme-aware styling.

- **feat(lib-yui): TreeDB graph node redesign**.  Unified node
  design system: doc-style HTML node cards (theme-aware), soft
  topic palette, rectangular leaves, size tiers, ports back to
  the topic colour, no circles, click-detail popover (no hover
  tooltips), redesigned edges/ports, dark-theme contrast fix.

- **feat(lib-yui): graph toolbar / context-menu**.  All
  toolbar/context-menu icons unified on one FA7 sprite;
  theme-aware G6 context menu (readable in dark); G6 popup
  transitions disabled (immediate, no glide); "reset zoom" home
  icon restored; bolder/longer "create node" plus.

- **feat(lib-yui): shell / nav**.  View-owned dynamic 3rd-level
  runtime subroute; unknown route falls back to the default
  route; secondary-nav zone collapses from config; single-row
  toolbar on touch; `toolbar type:"connection"` + `context_action`
  + modal `on_close`; TreeDB topics persist the selected topic
  across reloads with self-contained tab navigation; TreeDB
  table row-count footer and email/tel/url subtypes; edit/delete
  modal mount fallback.

- **fix(lib-yui): self-containment / responsiveness**.
  `C_G6_NODES_TREE` self-contained `ResizeObserver` and toolbar
  reconfig guarded until the graph is rendered;
  `C_YUI_TREEDB_GRAPH` emits `EV_OPERATION_MODE_CHANGED`; g6
  views detect theme from `<html data-theme>`; `C_YUI_UPLOT`
  responsive width.

- **feat(treedb): system schema v5 → v6**.  Restore the
  `cols.topics` fkey, refresh `cols` topic, show all system
  topics; keep the original ArtGins start year as a range
  (2024-2026).

- **feat(ycommand): `--editor`/`-e` and stdout dump**.  Dump a
  file to stdout when stdout is not a TTY (was: always vim);
  `ac_read_file` signals exit on success, not just on error;
  `pty_sync_spawn` drains the master pty after child exit
  (was: truncated `cat`).

- **feat(c_mqiogate): `broadcast` method** to fan out events to
  every child (tidy `lastdigits` to mirror it).

- **chore(packages / ci)**.  Ship sanitized agent JSON templates
  from the repo (drop the `/yuneta/agent/` dependency); move
  `RELEASE` to the repo root; `release-deb` generates a default
  `.config` via `alldefconfig` and drops the pgdg apt source.

- **chore(ext-libs): TODO bump nginx 1.28.3 → 1.30.1**
  (CVE-2026-42945) for the next ext-libs refresh.

- **misc(C)**: remove `sf_deleted_record` flag; fix stale
  `md2_record_t` "Size: 96 bytes" comment; log the key with bad
  metadata; revert the `C_TCP_S channel_filter` two-TLS-listener
  change.

- **feat(tr2list): `--dry-run` and `--follow` modes**.
  `--dry-run` / `-n` prints the resolved search parameters and
  the `match_cond` JSON (times already resolved by `approxidate`),
  plus a human-readable rendering of any `from-t`/`to-t`/`from-tm`/
  `to-tm` set — respects `--print-local-time` and flags millisecond
  input.  `--follow` / `-F` opens an `rt_disk` list and runs
  `yev_loop_run` until SIGINT (tail-f style; single topic, so it
  errors out when combined with `--recursive`).  `--help` now
  documents the full `approxidate` grammar accepted by TIME
  options (units, specials, absolute forms) via argp's `\v`
  separator.
- **feat(treedb_list): `--dry-run` and `--follow` modes**.
  `--dry-run` / `-n` runs `resolve_treedb_path` and prints the
  deduced path / database / topic alongside the filter and
  options JSON (also flags resolution failure and falls back to
  the raw user input).  `--follow` / `-F` keeps listening for
  node CREATED / UPDATED / DELETED / LINKED / UNLINKED events
  after the initial listing — uses the existing rt_disk path
  that treedb already opens internally when `master=false`, plus
  a `treedb_set_callback` that honours `--topic` and `--ids`.
  Errors out on `--follow --recursive` and on `--follow` with
  `--print-tranger` / `--print-treedb`.
- **fix(helpers/approxidate): accept short unit suffixes**.
  `1s`, `1m`, `1h`, `1d`, `1w`, `1M`, `1y` (plus `1sec`, `1mi`,
  `1min`, `1mo`, `1hr`, `1wk`, `1yr`) now resolve to the
  expected relative duration instead of silently falling through
  to the numeric date parser as day-of-month / year.  Lowercase
  `m` keeps minute, uppercase `M` is month (case-sensitive to
  disambiguate, mirroring `sleep` / `find -mmin` conventions).
  `mon` is intentionally left as Monday so weekday parsing
  keeps its existing behaviour.  Benefits every yuneta tool
  that consumes `approxidate` (tr2list, treedb_list, ybatch,
  tr2search, tr2keys, tr2migrate, ...).
- **refactor(tr2list): simpler `--dry-run` time block**.
  Each time line now ends with `(<show_date_relative>)` —
  `2 hours ago`, `3 days ago`, etc. — instead of echoing the
  raw input and warning about parser footguns.  The warning
  block in `--help` is dropped and replaced by a `short`
  group listing the new 1-3 char unit forms accepted by
  `approxidate`.

v7.3.2 -- 09/May/2026

- **feat(release): publish runtime `.deb` on GitHub Releases +
  one-liner `install.sh`**.  First CI workflow in the repo
  (`.github/workflows/release-deb.yml`) builds the AMD64 `.deb`
  on `release.published` (or `workflow_dispatch` against an
  existing tag) via `packages/AMD64.sh` and uploads it as a
  release asset.  Pairs with a new `install.sh` at the repo
  root: a POSIX one-shot installer that detects host arch
  (`amd64` / `armhf` / `riscv64`), queries the GitHub Releases
  API for the latest (or pinned) tag, downloads the matching
  `yuneta-agent-*-<arch>.deb`, and installs it via
  `dpkg + apt-get -f`:

      curl -fsSL https://raw.githubusercontent.com/artgins/yunetas/main/install.sh | sudo sh

  Pin a version with `sudo sh -s -- 7.3.2`.  ARMhf / ARM32 /
  RISCV64 wait for cross-compile or matching runners.  Past
  releases (7.2.0 .. 7.3.1) have no `.deb` assets — the
  workflow operates forward.

- **refactor(packages): extract `RELEASE` to a shared
  `packages/RELEASE` file** (reset to `1`).  The four arch
  wrappers had `RELEASE` hardcoded with divergent counters
  (3× "9", 1× "6"); now all four read from one file the same
  way they read `YUNETA_VERSION`.  Yunetas isn't widely
  distributed yet, so the renumbering is harmless.

- **docs(installation): rewrite as 7-step "guía burros" path**.
  `installation.md` restructured: prerequisites + 7 numbered
  steps from "create the `yuneta` user" through "build and
  test", with verbose detail (apt explanations, miniconda
  bootstrap, full `menuconfig` options) tucked into dropdowns.
  Adds a top-of-page **Quick install** section with the
  `install.sh` one-liner and clarifies that the PyPI `yunetas`
  package (0.x) is the management CLI, **not** the framework
  runtime (7.x).  Step 5 documents the env vars `yunetas-env.sh`
  exports — `YUNETAS_BASE`, `YUNETAS_OUTPUTS`, `YUNETAS_YUNOS`
  — plus the `PATH` prepends and the layout contract
  (`outputs/` and project repos as siblings of the `yunetas`
  repo).  Adds an explicit "re-source per shell" warning, a
  silent footgun in cron / SSH / CI sessions where
  `ybatch` / `ycommand` vanish from `PATH`.

- **fix(gobj-js): `DTP_STRING` attr coerces null / undefined to
  `""`**.  `json2item` used `JSON.stringify()` as the catch-all
  coercion for non-string values; for `null` that produced the
  literal 4-char string `"null"`, which leaked into IEvent
  payloads.  Specifically: `c_ievent_cli`'s `IDENTITY_CARD`
  sent `"jwt": "null"` to the backend, defeating the
  `empty_string()` check in `c_ievent_srv` that drives the BFF
  httpOnly-cookie auth path; `verify_token` then tried to
  validate the literal `"null"` as a JWT and failed with
  "No OAuth2 Issuer found".  Treat `null` and `undefined` as
  `""` in `DTP_STRING` to bring JS in line with the C runtime
  (where `DTP_STRING` cannot hold a `NULL` pointer).

- **refactor(C kernel): log hygiene for monitor stats**.  Several
  warning counters in the global-warnings dashboard were noisier
  than they needed to be:
    * `c_auth_bff`: 4xx HTTP responses logged as warning instead
      of info (5xx still error).  Sudden 4xx bursts now show in
      dashboards that filter on warning severity.
    * `c_prot_mqtt`, `c_prot_mqtt2`: malformed CONNECT frames
      (client-side protocol issues) downgraded from error to
      warning; rejection messages tightened so v1 and v2 paths
      bucket into the same precise counter.  Dump the offending
      gbuf when `handle__connect` returns < 0 so the bad CONNECT
      can be inspected in the trace.
    * `c_authz`, `c_ievent_srv`: dropped the duplicate
      "Authentication rejected" warning in `c_ievent_srv` (each
      `result < 0` path in `c_authz::mt_authenticate` already
      logs its own); audited `mt_authenticate` so every
      `result < 0` contributes to the per-msg stats counter;
      fixed a `peername`-empty branch whose `msg` field was
      leaking into the unrelated `dst_service`-not-found stats
      bucket.
    * `ydaemon`: translated the lone Spanish `msg` field
      ("Soy el Matador" → "I am the killer") so monitors and
      search tools group cleanly under the English-only
      convention.

- **feat(lib-yui): toolbar brand / avatar / dropdown item types
  with per-item `show_on`**.  Three new toolbar item kinds
  validated by `shell_toolbar_helpers.js` and rendered by
  `c_yui_shell.js`: `type:"brand"` (logo image + wordmark, with
  optional action — passive `<div>` if action is omitted),
  `type:"avatar"` (circular initials rendered from a
  host-registered provider via the new
  `yui_shell_set_avatar_provider` /
  `yui_shell_refresh_avatars` helpers), and
  `action.type:"dropdown"` (panel mounted on the popup layer
  with `divider` entries, focus-trap, escape-stack push/pop and
  capture-phase click-outside dismissal — closes on `scroll`
  and `resize` to match native `<select>` UX).  `show_on` now
  applies per item, not just per area.  CSS for all three
  shipped, SHELL.md §3.4 cheatsheet rewritten and §10
  "Implemented" updated.  23 new unit tests for the validators,
  27 chromium e2e specs still pass.

- **build(linux-ext-libs): nginx / openresty link against system
  libs; ncurses switched to widec for UTF-8**.  The vendored
  OpenSSL / PCRE2 stay only for yuneta's own static binaries
  (`ytls`, `yev_loop`); nginx and openresty now embed
  `libssl` / `libcrypto` / `libpcre` / `libz` from the host,
  same as the distro-packaged nginx — closes a latent
  Makefile-clobbering bug in `re-install-libs.sh`.  Ncurses
  re-enabled `--enable-widec` (v1.11) so `ycli` and `mqtt_tui`
  render UTF-8 emoji / accents instead of `M-x` escape
  sequences; consumers migrated to `<ncursesw/...>` and call
  `setlocale(LC_ALL, "")` before `initscr()`.  Also:
  `MAKEFLAGS=-j$(nproc)` for parallel builds, mbedtls Debug →
  Release, and explicit Release+static+PIC flags across mbedtls
  / jansson / pcre2 / libbacktrace / argp-standalone.

- **fix(c_auth_bff): shrink `legacy_base` buffer to silence
  `-Wformat-truncation`**.  PATH_MAX-sized `legacy_base` plus
  `/token` or `/logout` suffix into a PATH_MAX destination
  tripped GCC's truncation analysis.  A legacy Keycloak base
  URL is realistically well under 1 KB.

- **fix(lib-yui): TomSelect re-initialisation guards**.  The
  "Tom Select already initialized on this element" exception
  could be thrown when `build_topic_modal` ran twice — the
  query for `.select2-multiple` was matching inputs in earlier
  modals still attached to the popup-layer.  Scope the query
  to the freshly built `$element`; also add a defensive skip
  when the element already has a `tomselect` instance.

- **feat(gobj-c, gobj-js): EV_ON_OPEN_ERROR — close before open**.
  When a connection-oriented gobj closes before ever opening (TCP
  connect failed, TLS cert refused, non-101 handshake response,
  handshake timeout, firewall) it now publishes a separate
  `EV_ON_OPEN_ERROR` instead of `EV_ON_CLOSE`, preserving the
  EV_ON_OPEN→EV_ON_CLOSE FSM contract for subscribers that only
  handle close in their connected state.  Declared as a kernel
  event in `g_ev_kernel.{h,c}` and wired in:
    * `kernel/c/root-linux/src/c_ievent_cli.c` (IEvent client)
    * `kernel/c/root-linux/src/c_websocket.c` (low-level WS)
    * `kernel/js/gobj-js/src/c_ievent_cli.js` (browser client)
  Mirrors the browser WebSocket split (.onopen/.onclose/.onerror).
  Flagged with `EVF_NO_WARN_SUBS` so backend FSMs that ignore it
  don't trip the no-subscribers warning; interactive frontends
  opt in.  Retry policy unchanged: the connection-responsible
  gobj keeps reconnecting forever while running — only the parent
  (by stopping the gobj) decides to give up.  Each emission also
  writes a `log_warning` (`MSGSET_CONNECT_DISCONNECT` in C)
  including the remote yuno identity / url / peername — gives
  logcenter and other monitors a precise per-attempt alert that
  a silent retry loop is in progress.

- **fix(lib-yui): bare-route redirect skips decorative items**.
  `navigate_to()` was using `submenu.items[0].route` as the
  fallback for a level-1 container — undefined when item 0 is a
  `type:"header"` / `type:"divider"`, which caused the bare route
  to fall through to "no target".  Use the first item with a
  `route` instead; `submenu.default` still wins.  SHELL.md §3
  updated.

- **feat(lib-yui): item tooltips**.  Nav and toolbar items accept a
  `tooltip` field (fallback: `aria_label`); rendered as the HTML
  `title` attribute on the generated `<a>`/`<button>`.

- **feat(yuno-skeleton): `js_gui` template**.  New skeleton type for
  JS GUI yunos — Vite + lib-yui declarative shell with locales/
  (en+es), public/ web assets, 5 placeholder primary areas, and a
  burger drawer hosting Account + Help.  Registered in
  `__skeletons__.json` (type: Yuno; vars: version, description,
  author, author_email, license_name).

- **feat(gobj-js, lib-yui): translatable tooltips**.  Nav and toolbar
  items rendered by lib-yui now also emit `data-i18n-title="<key>"`
  next to their `title` attribute, and `refresh_language()` in
  gobj-js gained a second pass that walks `[data-i18n-title]` and
  re-translates the `title`.  Hover tooltips swap language alongside
  the visible labels.

- **feat(gobj-js, lib-yui): translatable aria-labels**.  Nav and
  toolbar renderers now also emit `data-i18n-aria-label="<key>"`
  next to their `aria-label` attribute (toolbar root, action items,
  brand, avatar, dropdown panel, dropdown rows, and nav items), and
  `refresh_language()` walks `[data-i18n-aria-label]` to rewrite
  `aria-label`.  Screen-reader names now follow the active locale.

- **fix(lib-yui): toolbar dropdown anchor drift on scroll/resize**.
  The `position:fixed` panel coordinates are frozen at open time
  from `getBoundingClientRect()`; any layout shift previously left
  the panel detached from its trigger.  Match native `<select>` UX
  and dismiss the dropdown on scroll (capture, passive — catches
  every ancestor scroller) and on window resize.

- **refactor(gui_treedb): apply locale convention**.  Trimmed
  en.js/es.js to the 19 keys actually called from src/ (auth_bff
  protocol IDs + the half-dozen `t(...)` calls in
  `c_yuneta_gui.js`); deleted ~140 aspirational entries that had
  no caller.  Renamed `remote-service` → `remote service`,
  `connection-backend-refused` → `connection to backend refused`
  (rule: spaces, not kebab); fixed top-level `nombre:` → `name:`
  to match the rest of the codebase.  Added
  `keySeparator: false` + `nsSeparator: false` in
  `setup_locale()` so a future dotted key (e.g. device-namespace)
  doesn't fall silently to nested-lookup.  Same
  `scripts/validate-locales.mjs` + `prebuild` wiring as
  wattyzer.  Auth_bff snake_case codes kept as-is (wire
  contract, see `c_auth_bff.c`).

- **feat(yuno-skeleton): locale convention + validator**.  The
  `js_gui` template now ships `scripts/validate-locales.mjs`
  (asserts every i18n key is ASCII + lower-case + present in every
  locale) wired as `npm run validate-locales` and `prebuild`.
  en.js/es.js header banners spell out the convention so new
  yunos inherit it from day one.

v7.3.1 -- 30/Apr/2026

- **breaking(auth): standard OIDC migration of `c_auth_bff` and
  `c_task_authenticate`**.  Both gclasses now resolve IdP endpoints
  in the same priority order:

    1. Explicit `token_endpoint` + `end_session_endpoint` attrs
       (full URLs, skips discovery — one fewer round-trip).
    2. `issuer` attr — task chain prepends a GET of
       `<issuer>/.well-known/openid-configuration` and caches the
       resolved endpoints in priv before the auth flow runs.
    3. Refuse to start.

  Any conformant OIDC IdP works (Keycloak, Auth0, Cognito, Azure AD,
  Authentik, ...).  Hardcoded Keycloak path scheme removed.

  - **`c_task_authenticate` and its 6 callers** (`c_cli`, `c_mqtt_tui`,
    `c_ycommand`, `c_ystats`, `c_ytests`, `c_ybatch`) had their
    legacy `auth_url`+`auth_system` attrs **removed outright** and
    the `azp` attr **renamed to `client_id`** to match the form
    parameter actually sent on `/token` and `/logout`.
  - **CLI flag set** in `ycommand` / `ystats` / `ytests` / `ybatch` /
    `mqtt_tui` is now `-I/--issuer`, `-T/--token-endpoint`,
    `-E/--end-session-endpoint`, `-Z/--client-id`.  Old `-K/--auth_system`,
    `-k/--auth_url` and `-Z/--azp` (renamed) are gone.
  - **`c_auth_bff` keeps `idp_url`+`realm`** as a deprecated path
    (warning fired at `mt_create`); removal scheduled once one
    release has shipped with the warning in place.  See
    [`TODO.md`](TODO.md) for the remaining smoke tests against
    non-Keycloak IdPs and the open ROPC-vs-PKCE question.

- **feat(gobj, gobj-js): `SDF_DEPRECATED` attribute flag**.  New
  sdata flag (`0x00000100`) to mark a gclass attribute as deprecated.
  Both the C runtime and the JS runtime emit a warning when a
  deprecated attribute is set during gobj creation, naming the
  gclass and the attr.  First adopter: `c_authz::authz_yuno_role`
  (use `authz_service` instead).

- **test(c_task_authenticate)**: new self-contained suite under
  `tests/c/c_task_authenticate/` (`test1_discovery`,
  `test2_explicit_endpoints`, `test4_discovery_failure`).  Mock IdP
  gclass with `override_*_body` knobs for failure injection;
  shared `test_main.c` boilerplate; the driver subscribes to
  `EV_ON_TOKEN`, asserts the result code, and dies.

- **test(c_auth_bff)**: new `test17_legacy_idp_url` covers the
  `idp_url`+`realm` deprecation path that tests 1–16 missed.
  Captures the deprecation warning at `LOG_OPT_UP_WARNING` and
  drives the full login flow against the same mock-Keycloak.

- **feat(lib-yui): declarative app shell `C_YUI_SHELL` + `C_YUI_NAV`**.
  A JSON-driven replacement for `C_YUI_MAIN` + `C_YUI_ROUTING`, shipped
  alongside the legacy stack (no migration planned — see
  [`SHELL.md` §10](kernel/js/lib-yui/SHELL.md)).  New GUIs can adopt
  the new shell; existing GUIs keep using the old one unchanged.
  - **Layered grid**: 6 z-stacked layers (`base`, `overlay`, `popup`,
    `modal`, `notification`, `loading`) and 7 zones (`top`, `top-sub`,
    `left`, `center`, `right`, `bottom-sub`, `bottom`) inside `base`,
    all driven by a single declarative JSON config.
  - **Six menu layouts**: `vertical`, `icon-bar`, `tabs`, `drawer`,
    `submenu`, `accordion`.  Same menu may render differently per
    zone via `render[zone]`.  Auto-expand of the active branch on
    accordion when the route changes.
  - **`show_on` parser**: zone visibility per Bulma breakpoint with
    the operators `>=`, `<=`, `<`, `>`, enumeration and `|`.  Pure
    module (`shell_show_on.js`), 13 `node --test` unit tests.
  - **Three lifecycle modes per item** (`eager` / `keep_alive` /
    `lazy_destroy`) decide when the routed view is created and
    destroyed.
  - **Single router**: `C_YUI_NAV` publishes `EV_NAV_CLICKED`; the
    shell publishes `EV_ROUTE_REQUESTED` (intent, audit witness)
    and `EV_ROUTE_CHANGED` (fact).  Hash-based 2-level routing,
    no dependency on `C_YUI_ROUTING`.
  - **Drawer overlay** on the `overlay` layer with focus-trap
    (Tab/Shift+Tab cycling, focus restoration on close), backdrop
    click closes via `EV_DRAWER_CLOSE_REQUESTED` (canonical close
    path with focus-trap release + escape-stack pop).
  - **Escape priority chain**: `priv.escape_stack` is a LIFO of
    `{layer, handler}`; the global `keydown` listener calls only
    the top entry.  Modal-over-drawer closes the modal first.
    Public API `yui_shell_push_escape` / `yui_shell_pop_escape`
    for app-level overlays.
  - **Modal / notification API** on top of the shell layers
    (`yui_shell_show_info` / `show_warning` / `show_error` /
    `show_modal` for non-blocking; `yui_shell_confirm_ok` /
    `confirm_yesno` / `confirm_yesnocancel` for blocking dialogs
    that resolve a Promise).  Each modal/dialog auto-pushes onto
    the Escape stack and installs a focus-trap.  Bulma `.modal-card`
    / `.notification` markup verbatim.  Generic focus-trap moved
    to `shell_focus_trap.js` with 10 unit tests.
  - **Canonical i18n via `data-i18n` + `refresh_language`**: every
    translatable text node carries `data-i18n="<canonical key>"`;
    apps switch language by calling
    `refresh_language(shell.$container, t)` from `@yuneta/gobj-js`,
    the same flow `c_yui_main.js` uses in `change_language()`.
    Modals/dialogs accept `opts.t` so they render in the active
    language at open time AND retranslate live afterwards.
  - **Generalised secondary-nav loop**: `instantiate_menus()` walks
    every menu mounted via a `"menu.<id>"` host whose items declare
    a `submenu` (not just `menu.primary`).  Synthesised menu_id is
    `secondary.<owning_menu_id>.<item.id>`, scoped so two
    primary-style menus can share item ids without colliding.
  - **`gcflag_no_check_output_events`** on the shell so the toolbar
    can publish arbitrary user-defined events
    (`action.type:"event"`) without each app having to extend the
    shell's `event_types` table.
  - **Hard contracts**: every view gclass MUST expose `$container`
    in `mt_create`; every navigation through an empty/unknown route
    logs `log_error` and surfaces a placeholder banner; every
    try/catch logs via `log_warning` (no silent swallow).
  - **`validate_config()`**: system-boundary guard run at the top
    of `mt_start`.  Rejects malformed configs with a visible
    "invalid config" banner instead of producing a half-built
    shell.  Checks: object/array shapes, zone-id membership in the
    7 valid zones, `host` syntax (`toolbar` | `menu.<id>` |
    `stage.<id>`), stage zones declared in `shell.zones`, and
    cross-menu route-target uniqueness (warn when two menus claim
    the same target).
  - **Playwright e2e harness**: 22 spec files × 3 browsers
    (chromium + firefox + webkit) = 69 tests covering boot /
    navigation / drawer / modals / multimenu / validator /
    lifecycle / breakpoint / live-i18n.  CI workflow
    `.github/workflows/lib-yui.yml` runs unit + e2e on PRs and
    pushes touching `kernel/js/lib-yui/**` or
    `kernel/js/gobj-js/**`.  `kernel/js/lib-yui/install-e2e-deps.sh`
    helper installs the apt packages WebKit links against
    (`libgstreamer-plugins-bad1.0-0`, `libavif16`).
  - **Test-app**: standalone harness in `kernel/js/lib-yui/test-app/`
    with three presets (`default`, `?preset=accordion`,
    `?preset=multimenu`) plus a deliberately-broken `?preset=invalid`
    used by the validator regression test.  `C_TEST_LANG`
    controller demonstrates the canonical pattern for reacting to
    custom toolbar events (language toggle, hello toast, ask
    dialog).
  - **Docs**: [`SHELL.md`](kernel/js/lib-yui/SHELL.md) (design,
    configuration JSON, GClasses + events, modal/notification API,
    Escape chain, internationalisation),
    [`TODO.md`](kernel/js/lib-yui/TODO.md) (status of every task on
    the new shell), updated `lib-yui/README.md` with the
    "Which app shell to use?" decision tree.
  - **CLAUDE.md**: new "GClass section layout" addendum (JS skeleton
    banners + canonical CHILD/SERVICE subscription model + Always
    braces rule + EVF_NO_WARN_SUBS) so future agents stay on the
    rails the user established for this work.

v7.3.0 -- 18/Apr/2026

- **feat(ytls, c_yuno, c_agent): TLS certificate hot-reload with
  three-layer defence-in-depth**. Lets a Yuneta host keep thousands of
  persistent TLS connections alive across a Let's Encrypt renewal,
  with no deploy-hook single point of failure.
  - **ytls**: new [`ytls_reload_certificates()`](docs/doc.yuneta.io/api/ytls/ytls.md)
    that rebuilds the backend context (OpenSSL `SSL_CTX` or mbed-TLS
    `mbedtls_state_t` bundle), validates it, and atomically swaps it
    in. Live sessions hold their own refcount on the previous context,
    so already-established connections keep working until they close.
    Invalid material rolls back cleanly — traffic is never interrupted
    by a bad reload. `ytls_get_cert_info()` returns
    `{subject, issuer, not_before, not_after, serial, days_remaining}`
    for the live context, not just the file on disk.
  - **c_agent**: new cert auto-sync timer (attr
    `cert_sync_interval_sec`, default 900 s) that re-reads
    `/yuneta/store/certs/` via `sudo -n copy-certs.sh`; when any
    `size+mtime` changes, broadcasts `reload-certs` to every running
    yuno. Exposes `cert-sync-now` / `cert-sync-status` commands and
    self-heals if the certbot deploy hook fails silently.
  - **c_yuno**: periodic expiry monitor (attr `timeout_cert_check`,
    default 3600 s) that walks every `C_TCP_S` / `C_UDP_S` listener
    and logs `gobj_log_warning()` at `cert_warn_days` (default 7) and
    `gobj_log_critical()` at `cert_critical_days` (default 2).
    Alert-only — the sync layer owns the reload responsibility.
  - **c_tcp_s / c_udp_s**: per-listener `reload-certs` and `view-cert`
    commands, routable via `ycommand -c 'command-yuno command=reload-certs
    service=__yuno__'` or `gobj=<name>` for a single listener.
  - **packages**: `/etc/letsencrypt/renewal-hooks/deploy/reload-certs`
    hook copies certs, reloads the web server and broadcasts the
    yuno-level reload. Each step runs with `set +e`; output is logged
    to `/var/log/yuneta/deploy-hook.log` and the hook writes its last
    run timestamp to `/var/lib/yuneta/last-deploy-hook-run` so
    `cert-sync-status` can spot a hook that never runs.
  - **tests**: `tests/c/ytls/test_cert_reload`,
    `test_cert_info`, `test_cert_reload_mem` (1000 reloads, zero leak)
    and `tests/c/yev_loop/yev_events_tls/test_yevent_reload_live`,
    `test_yevent_reload_stress` (50 reloads with a live session).
  - **docs**: new guide [`guide/guide_cert_management.md`](docs/doc.yuneta.io/guide/guide_cert_management.md)
    covers the end-to-end story, layered design and file / permission
    layout; `guide/guide_ytls.md` gains a hot-reload section.

- **feat(gobj): `gobj_set_manual_start()` + `gobj_flag_manual_start`**.
  A gobj can now opt out of the automatic `start-tree` walk so its
  parent keeps ownership of lifecycle but decides *when* to bring it
  up. Used in `c_auth_bff` to keep `gobj_idprovider` dormant until the
  BFF has validated its configuration.

- **feat(ycommand)**: major interactive / scripting overhaul.
  - TAB completion of command names, parameter names and boolean values,
    from a remote `list-gobj-commands` cache fetched at connect time
    (routed through `service=__yuno__`) and from a local command table
    for `!cmd` built-ins.
  - Inline parameter hints in gray (`<name=type>` required,
    `[name=type]` optional, already-typed params dropped).
  - Connect-time informative prompt (`<role>^<name>> `) and schema-driven
    table rendering in both interactive and non-interactive modes (use
    the `*cmd` prefix to force raw-JSON form).
  - `Ctrl+R` / `Ctrl+S` incremental history search, `Ctrl+L` clear screen,
    bash-style `!!` / `!N` history expansion, erasedups history.
  - c_cli-style local commands via the `!` prefix: `!help` (alias `!h` /
    `!?`), `!history`, `!clear-history`, `!exit` / `!quit`,
    `!source <file>` (alias `!.`). Full keybinding + syntax reference
    available as `!help` and in `utils/c/ycommand/README.md`.
  - Command chaining with `cmd1 ; cmd2 ; cmd3` (quote/brace-aware split),
    `-cmd` ignore-fail (ybatch convention), stdin piping
    (`cat batch.ycmd | ycommand -u ws://...`). A single shared
    command queue drains one command at a time, waiting for the previous
    response before sending the next.
  - `did-you-mean` suggestions on `command not available` errors,
    Levenshtein-matched against the cache.
  - Positional command form (`ycommand kill-yuno id=foo`, equivalent to
    `-c`). The `-c` flag still wins when both are present.
- **feat(c_editline)**: new public helpers shared by every editline
  client — `editline_set_completion_callback` /
  `editline_set_hints_callback` / `editline_add_completion` /
  `editline_history_count` / `editline_history_get`. New events
  `EV_EDITLINE_REVERSE_SEARCH` / `EV_EDITLINE_FORWARD_SEARCH` for
  incremental history search; candidate list + description is rendered
  on TAB when multiple options exist.
- **fix(c_editline)**: after the user selects a TAB candidate, the
  keystroke that committed the selection (Enter, Backspace, printable)
  is now re-dispatched so the action takes effect in the same press
  instead of requiring a second press.
- **fix(ycommand)**: `on_read_cb` no longer drops trailing bytes of a
  batched read that matched a keytable entry, so rapid TAB+value typing
  no longer needs a second press.
- **feat(ycli)**: TAB completion brought in line with ycommand, adapted
  to the multi-window ncurses UI.
  - `!cmd<TAB>` completes local `c_cli` commands; `cmd<TAB>` (no `!`)
    completes remote commands of the yuno attached to the focused
    display window. Cache is per-connection, fetched silently on
    `EV_ON_OPEN` via `list-gobj-commands` and dropped on
    `EV_ON_CLOSE`.
  - Multi-candidate list is rendered in a temporary ncurses popup
    above the editline (no more blocking `read(STDIN_FILENO)` inside
    the yev_loop callback); cycling is driven through the normal FSM
    (TAB / Up / Down navigate, Enter commits to the edit line only,
    Esc / Ctrl+G / Backspace cancel, printable keys commit + insert).
  - Scrollable popup with a status row (`N/M  ↑ K above  ↓ L below`)
    rendered in dim attributes so A_REVERSE on the selected row can
    never bleed into it.
  - Inline hints (`<req=type>` / `[opt=type]`) in gray (A_BOLD on
    COLOR_BLACK = bright-black / gray in most terminals).
- **feat(c_editline)**: new `EV_EDITLINE_CANCEL` event for escape-style
  cancellation of reverse-i-search and TAB-popup sub-modes; `refreshSearchLine`
  now draws through ncurses (`wmove/waddnstr/wrefresh`) on `use_ncurses`
  clients instead of bypassing the pane via `printf`.
- **feat(ycli / ycommand)**: `Ctrl+K` switched to readline semantics —
  delete from cursor to end of line (`EV_EDITLINE_DEL_EOL`).
  `Ctrl+U` / `Ctrl+Y` remain "delete whole line"; `Ctrl+L` is the
  clear-screen shortcut (previously shared with `Ctrl+K`).
- **docs**: added `utils/c/ycommand/README.md`, `TODO.md` and updated
  `docs/doc.yuneta.io/{utilities,yunos,modules}.md` to cover the new
  features.

- **API change(ghttp_parser)**: `ghttp_parser_reset()` is **removed** from
  the public API.  It was a foot-gun: calling it from inside an llhttp
  callback (as `on_message_complete` used to do) corrupted llhttp's state
  machine and silently swallowed pipelined messages.  Callers that need a
  pristine parser for a new connection now use the destroy+create cycle
  (see `c_prot_http_sr::ac_connected`, `c_prot_http_cl::ac_connected`,
  `c_websocket::ac_connected`).  The llhttp settings vtable is now
  initialised once, lazily, via `llhttp_settings_init()` in
  `ensure_settings_initialized()`.
- **feat(ghttp_parser)**: new `ghttp_parser_finish()` that signals
  end-of-stream (`llhttp_finish()`) to the parser.  Fixes a latent bug
  where HTTP/1.0 responses (or HTTP/1.1 `Connection: close` responses
  without `Content-Length` / `Transfer-Encoding: chunked`) never fired
  `on_message_complete` because the peer's socket close was the only
  message terminator.  Wired up in `c_prot_http_cl::ac_disconnected`
  (the critical case for response parsers), `c_prot_http_sr::ac_disconnected`,
  and `c_websocket::ac_disconnected`.
- **fix(ghttp_parser)**: on `HPE_PAUSED_UPGRADE`, `ghttp_parser_received()`
  now returns the actual number of bytes llhttp consumed (computed via
  `llhttp_get_error_pos()`) instead of lying that it consumed the whole
  buffer.  This lets the caller re-route any tail bytes that belong to
  the new protocol (e.g. a WebSocket frame piggy-backed on the same TCP
  segment as the upgrade request) to the next handler.
- **CRITICAL fix(ghttp_parser)**: HTTP/1.1 pipelining was silently broken —
  `on_message_complete()` called `ghttp_parser_reset()`, which in turn called
  `llhttp_init()` from inside the llhttp callback, corrupting the parser's
  internal state machine so every subsequent message in the same buffer was
  swallowed without a log.  Affects every yuno serving or consuming HTTP
  over keep-alive when more than one message is in flight on a single
  connection (c_prot_http_sr, c_prot_http_cl, c_websocket).  Fix: reset the
  per-message app fields inline in `on_message_complete` without touching
  llhttp; leave `ghttp_parser_reset()` for the other (non-callback) call
  sites.  Surfaced by the new test suite `tests/c/c_auth_bff/test8_queue_full`.
- **refactor(c_auth_bff): IdP-agnostic naming, single-job task, queue +
  routing hardening**. The BFF used to be visibly wired to Keycloak
  (`kc_*` attrs, stats, logs). Code, attrs and stats now use the
  generic `idp_*` prefix; any OIDC provider fits. The outbound IdP
  gobj chain is now named `<bff-name>-idp` for trace clarity.
  - **Pending queue** migrated from a fixed-size `PENDING_AUTH *` ring
    to a `dl_list`, drained one job at a time. Configurable per
    instance via `pending_queue_size` (default 16, clamped to
    `[1, 1024]`). Overflow bumps `q_full_drops` and the browser sees
    a mapped `error_code`; peak depth is exposed as `q_max_seen`.
  - **Flush-on-disconnect**: when a browser closes mid-round-trip the
    BFF flushes its pending queue for that channel; late IdP replies
    for disconnected clients are dropped (`responses_dropped` counter)
    instead of being forwarded. Each task also carries a per-browser
    generation so a cross-user token leak cannot occur.
  - **Single-job task, teardown-safe close**: the C_TASK instance
    holds a single job at a time; `mt_stop` drains the inbound
    `C_PROT_HTTP_SR + C_TCP` chain and the outbound `gobj_http` so a
    SIGTERM with live browser connections no longer logs
    "Destroying a RUNNING gobj".
  - **Outbound watchdog**: per-instance attr `idp_timeout_ms`
    (default 30000, 0 disables) armed via a `C_TIMER0` child right
    after the outbound HTTP client is created and cleared in
    `ac_end_task`. On fire, responds 504 to the browser and drains
    the task; closes the "IdP silence → channel wedged forever"
    deadlock. New `idp_timeouts` stat counter.
  - **IdP health signal fix**: count any 2xx IdP reply as `idp_ok`;
    previously only 200 counted, so every successful `/logout`
    (Keycloak returns spec-compliant 204 No Content) poisoned the
    ratio as an `idp_error`.
  - **Logout routing fix**: route the logout reply to the bottom
    browser channel, not to the dangling `_browser_src` from an
    earlier round-trip.
  - **`mt_stats` filter** mirrors the default `stats_parser.c`
    two-stage matcher (full name OR underscore-prefix) and is
    case-insensitive, so `gobj_stats(bff, "idp_", ...)` returns the
    idp_* set as expected. `redact_for_trace()` key matching is also
    case-insensitive so HTTP headers like "Cookie"/"cookie"/"COOKIE"
    are all masked.
  - **Stats moved to PRIVATE_DATA + `mt_stats`** for zero hot-path
    cost; the gclass now also exposes a stats/queue-state command
    through the normal command interface.
  - **Stable `error_code`** in every BFF response (snake_case, e.g.
    `invalid_refresh_token`, `idp_unreachable`, `queue_full`) — the
    GUI uses this as its i18n translation key. Action-aware error
    mapping wired through `gui_treedb`.
  - **Log hygiene**: 4xx IdP replies are logged as `INFO`, not
    `ERROR` (a wrong password is not a server error), with
    `MSGSET_PROTOCOL`. New `messages` / `traffic` trace levels; 👤
    BFF log prefix and ⏩/⏪ direction arrows across BFF traces.
  - **Own orchestrator GClass** at the top of the `auth_bff` yuno
    (replaces the citizen-yuno shortcut) and `gobj_idprovider` is
    tagged `gobj_flag_manual_start` so it stays dormant until the
    BFF validates its configuration.
  - `gobj_http` single-instance invariant is now asserted in debug
    builds to catch re-entrancy regressions.

- **perf(auth_bff)**: new `perf_auth_bff` ping-pong-style live
  throughput benchmark (`performance/c/perf_auth_bff/`). Default
  10 s run, ~180 000 ops on the reference box; registered as ctest.

- **test(c_auth_bff)**: 16-binary suite self-contained under
  `tests/c/c_auth_bff/` with a scriptable mock Keycloak
  (`c_mock_keycloak`): signed HS256 JWTs, configurable latency /
  status / body override. Covers login, callback, refresh, logout,
  validation errors, IdP 401, slow IdP, queue pipelining + overflow,
  browser cancel mid-round-trip, cancel-then-retry, cross-user stale
  replies, expired refresh, 405 / missing body / unknown endpoint.
  Gates the watchdog, `browser_alive`, flush-on-disconnect and
  ghttp_parser fixes.

- **test(c_llhttp_parser)**: sanity suite for the vendored llhttp
  library and the `ghttp_parser` wrapper (`tests/c/c_llhttp_parser/`).

- **stress(auth_bff)**: new concurrent stress runner
  (`stress/c/auth_bff/`) that exercises the pending queue, the
  watchdog and the flush-on-disconnect path.

- **fix(c_prot_http_sr)**: omit response body on 1xx / 204 / 304
  replies (RFC 7230). The parser path was emitting a body for these
  status codes, confusing downstream clients and tripping some
  proxies.

- **fix(c_task)**: `volatil` gobjs now self-destroy at end-of-work —
  making the long-standing `// auto-destroy` comment actually true.
  The outbound HTTP client used by the BFF is created `volatil` so
  teardown is explicit and framework-free (PR #95). Also silences
  the `-Wcomment` warning in the auto-destroy comment and dedups
  `TRACE_MESSAGES` / `TRACE_MESSAGES2` output.

- **fix(lib-yui)**: restore `publi_page` iframe rendering for
  logged-out users — a regression in the login split hid the public
  landing page behind the auth screen.

- **fix(ytls/openssl)**: guard `flush_clear_data` against a
  re-entrant `sskt` free under specific TLS teardown paths.

- **build(libjwt)**: yuno skeleton `CMakeLists.txt` templates now
  link `${JWT_LIBS}` out of the box (PR #92).

- **refactor(gobj)**: drop TLS knowledge from `gobj-c`, inject it
  from the ytls layer via a new `gobj_add_global_variable()`
  extension point. Removes the `CONFIG_HAVE_OPENSSL/MBEDTLS` `#if`
  blocks from `gobj_global_variables()` and keeps the core
  backend-agnostic — `root-linux`'s `yunetas_register_c_core()`
  publishes `__tls_library__` and `__tls_libraries__` at startup.

v7.2.1 -- 07/Apr/2026

- TLS: change Kconfig from radio (choice) to checkboxes — both OpenSSL and mbedTLS can be
  enabled simultaneously for runtime backend selection per connection
- TLS: add `__tls_libraries__` global variable (reports all compiled backends)
- Documentation: add Test Suite page, fix glossary warnings, improve gobj-js and lib-yui READMEs
- Remove obsolete defconfig and REVIEW.md
- Fix duplicate measure_times declarations in yev_loop.h

v7.2.0 -- 04/Apr/2026

- Fully static glibc binaries (CONFIG_FULLY_STATIC): GCC and Clang, with custom
  static resolver (yuneta_getaddrinfo) and NSS replacements (static_getpwuid, etc.)
- mbedTLS support as alternative TLS backend (~3x smaller static binaries vs OpenSSL)
- Fix mbedTLS bad_record_mac: accumulate TLS records before writing
- Add TRACE_TLS trace level and mbedTLS debug callback for TLS diagnostics
- JS kernel restructured: gobj-js (7.1.x) and lib-yui (7.1.x) published to npm
- Replace bootstrap-table+jQuery with Tabulator in gui_treedb
- Vite 8 build for lib-yui (ES/CJS/UMD/IIFE bundles)
- MQTT 5.0: will properties, user properties, topic alias, subscription identifiers
- Fix MQTT QoS 2 infinite loop and flow control (receive-maximum, keepalive)
- OAuth2 BFF (auth_bff yuno) with PKCE, httpOnly cookies, security hardening
- TreeDB: compound link improvements, undo/redo history sync, new tr2search/treedb_list utils
- G6 graph visualization: C_G6_NODES_TREE and C_YUI_JSON_GRAPH GClasses
- Fix c_watchfs: memory leak, event name mismatch (EV_FS_CHANGED), buffer bugs
- Fix c_fs: memory leak in destroy_subdir_watch
- Fix XSS vulnerabilities in gui_treedb webapp
- Kconfig: add CONFIG_C_PROT_MQTT, organize protocol modules submenu
- Remove deprecated musl compiler option

v7.0.1 -- 29/Mar/2026

- Release 7.0.1
- JS kernel (yunetas npm package) published as v0.3.0
- Updated and documented .deb packaging (packages/)

v7.0.0 -- 28/Sep/2025

- Publish first 7.0.0 for production

v7.0.0-b17 -- 26/Sep/2025

- fix remote console (controlcenter) blocked when paste text

v7.0.0-b15 -- 22/Sep/2025

- fix yuneta_agent: wrong assignment of ips to public service

v7.0.0-b14 -- 11/Sep/2025

- improve .deb
- yuno-skeleton to /yuneta/bin and skeletons to /yuneta/bin/skeletons
- check inherited files only for daemons

v7.0.0-b12 -- 7/Sep/2025

- now you can select openresty or nginx in .deb

v7.0.0-b10 -- 2/Sep/2025

- jwt in remote connection

v7.0.0-b9 -- 2/Sep/2025

- Remote control (controlcenter) ok

v7.0.0-b8 -- 29/Aug/2025

- GObj: fix bug with rename events

v7.0.0-b7 -- 29/Aug/2025

- Fixed: avoid that yunos (fork child) inherit the socket/file descriptors from agent.