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

Unreleased

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. claudia-console 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.