# CLAUDE.md

Guidance for Claude Code (claude.ai/code) when working with this repository.

## ⚠️ Yunetas is a consolidated project

Before proposing or applying any change:

1. **Read in depth** the affected code and its context — not just the fragment.
2. **Identify invariants and conventions** of the module (gclass patterns, FSM, `gbmem_*`, banner layout, etc.).
3. **Analyze side effects**: callers, tests, static builds, TLS backends.
4. **Justify the change** and wait for approval before touching anything.

"Obvious" or "small" changes have caused regressions. Speed does not compensate for breaking invariants.

### Restore, don't redesign

Dead branches, `if(0)` fences and TODO stubs that trace back to the pre-v7
codebase are usually designs validated by years of production use — the wiring
was cut during the v7 port, not the design. The default question is *"what
wiring is missing to restore the original behaviour?"*, never *"how do I
redesign this?"*. Treat "this looks odd" as a flag to investigate harder;
redesign only after citing the concrete scenario the original design breaks.
(This applies to prod-hardened legacy designs, not to new/experimental code
such as the gobj-ui v2 declarative shell.)

### `kernel/js/gobj-js/`, `kernel/js/gobj-ui/` and `yunos/js/` are git submodules

The C kernel and the runtime gclasses are consolidated: treat them as described
above.

The JavaScript layer is **not** consolidated — it lives in its **own
repositories** and is embedded here as **git submodules** (the same model as
`utils/python/tui_yunetas`), each at the same path it used to occupy:

- `kernel/js/gobj-js/` → `github.com/artgins/gobj-js` (tracks `main`) — the
  GObject-JS runtime.
- `kernel/js/gobj-ui/` → `github.com/artgins/gobj-ui.js` (tracks `main`/v2) — the
  UI library.
- `yunos/js/` → `github.com/artgins/yunos-js` (tracks `main`) — the JS **yunos**
  (browser SPAs: `gui_agent`, `gui_treedb`). Extracted from `yunetas/yunos/js`
  at 7.6.8 as a fresh snapshot (history stays in yunetas); it is the most
  active-changing JS layer, so it evolves on its own line with its own
  `CHANGELOG.md`.

**JS versions never get ahead of C, except in the third number** (rule of
2026-08-28). The first two numbers of an SDK JS package are those of
`YUNETA_VERSION`; the third is the package's own life between SDK releases. A
number that does not say which SDK the package goes with is of no use to
anybody: `gobj-js` was at 7.13.x while C was at 7.16.2, and it jumped to
**7.16.0** to say it again -- the number does not count releases, it names the
SDK the package belongs to.

**`gobj-ui` is back in line since SDK 7.23.0.** From 7.16.2 to 7.22.0 it was
ahead of C (its 7.23.x line was already published, and a lower number would
not have become `latest` on npm). SDK 7.23.0 reached its line, so the rule
holds again for `gobj-ui` too. **And it follows the SDK up, not only down:**
gobj-ui stayed on 7.23.x through SDK 7.24 and 7.25, a number that again said
nothing about its SDK, until it jumped to **7.25.0** (2026-09-22, no code
change over 7.23.197). When the SDK's minor moves, the next gobj-ui release
takes it.

Clone yunetas with `--recurse-submodules` (or run `git submodule update --init`).
**Every SPA consumes `@yuneta/gobj-js` / `@yuneta/gobj-ui` from the npm
registry** — since 2026-08-03 there are no `file:` consumers left (wattyzer
moved on 2026-07-25, the in-repo `yunos/js/*` yunos followed). So library work
reaches an app only once published, and each SPA builds standalone. To edit a
JS yuno: work in `yunos/js` directly, commit on `main` in the `yunos-js` repo,
then **bump this submodule pointer in yunetas** (same flow as gobj-js/gobj-ui).

The standalone repo carries **two maintained lines**, and they are consumed in
**two different ways** (since 2026-06-16):

- **`main` branch** (the v2 line, tag `2.0.0`+, npm `7.26.24`) — **active
  development**: the declarative shell (`C_YUI_SHELL/NAV/PAGER/WIZARD`; the
  legacy stack `C_YUI_MAIN/TABS/ROUTING` was removed from this line in `3.0.0`).
  Every npm-published release is git-tagged (backfilled 2026-07-17); `4.0.0`
  carries five BREAKING contract changes, and `5.0.0` is a **dependency-only
  major** (no API moved) that raises the peer floors — gobj-js `>= 7.8.7`,
  `maplibre-gl ^6.0.0` (ESM-only), i18next `^26.3.6`, tom-select `^2.6.2`,
  vanilla-jsoneditor `^3.13.0`. `6.0.0` is another dependency-only major (peer
  floor gobj-js `^7.13.2`, for the qualified pkey); `6.1.x` added
  `C_YUI_SCHEMA_EDITOR`, the schema editor the agent console's Schemas
  workspace lands on, and made the schema view's node click opt-in
  (`with_node_click`) after 6.1.1 published it under hosts that never asked;
  `6.2.x` lets a `C_YUI_NODE` tree remember where each child was left
  (`remember_position`), so a strip of children stops behaving like a row of
  tabs that forgets; `6.3.0` gives `C_YUI_TREEDB_TOPICS` a `source_url` it
  prints in its toolbar, because a tab labelled with a treedb name does not
  say which BACKEND that treedb is on; `6.3.x` stops the schema diagram
  rescaling itself the moment it appears and turns its arrowheads round to
  land on the parent's hook, where the `.c` literal and the `↖` of the fkey
  mark put them. `7.0.0` is a third dependency-only major, raising the
  `maplibre-gl` floor to `^6.4.1` — where `DOM.sanitize` stops skipping the
  attribute after each one it removes. `7.1.x`–`7.10.x` is the treedb GUI
  round: the topic table gets per-column filters, a column chooser, a CSV
  export and in-place editing of a writable scalar; the graph gets a find box,
  a topic-colour legend and a minimap, and opens a treedb nobody has arranged
  in `dagre` instead of a diagonal pile; a topic can be pulled a PAGE at a
  time (`with_remote_paging`, needs the SDK's paged `nodes`); and a delete
  finally says what it takes with it, which with `force` is every child
  UNLINKED. `7.11.x`-`7.14.x` adds the shared row-selection facility
  (`yui_table_select.js`: a tri-state parent checkbox, a selection bar, and
  bulk removal, taken by the treedb topic table behind `with_selection_bar`),
  gives a schema column drag an Undo, and sizes the schema editor's controls
  like controls instead of `is-small`. `7.15.x`-`7.19.x` is the graph round
  (multi-selection, `1:1` in place of a house, the camera saying its zoom) and
  the URL-as-position round. `7.20.x`-`7.23.x` is the JSON viewer: it reads
  the same document three ways — the lazy tree, the raw text, and a graph —
  and the three graphs of the library (`C_G6_NODES_TREE`,
  `C_YUI_JSON_GRAPH`, `C_YUI_GOBJ_TREE_JS`) end it speaking ONE toolbar
  vocabulary, built in `yui_graph_camera.js`. `7.23.9` makes those same three
  graphs OPERABLE on a touch screen — pinch, a long press that opens the
  context menu, touch-sized targets behind `(pointer: coarse)`, and the two
  floating toolbars folded behind one button on a narrow container — which
  cost two facts about G6 that its documentation does not carry: **it never
  reads the DOM's `contextmenu` event** (it synthesises the event from
  `pointerdown` with `button === 2`, so a long press reaches the plugin on no
  platform), and **`@antv/g` re-issues pointer ids mid-gesture**, so a
  two-finger gesture tracked by `pointerId` — which is how G6's own
  `PinchHandler` tracks it — loses a finger halfway. Read `event.touches`.
  `7.23.10` is the treedb graph's edition mode, and its lesson is one line:
  **a G6 behavior's `enable` REPLACES the default, it does not add to it** —
  the edition `drag-canvas` was given one so it would stand aside for Shift,
  which silently dropped G6's `targetType === 'canvas'` test, so every drag of
  a card panned the canvas as well as moving the card. Two more in the same
  place: the history plugin was tied to a MOMENT of the load rather than to
  the mode (so Undo was dead whenever edition was entered from the mode
  selector, while Save still lit), and G6's `brush-select` keeps the
  `selected` state itself — rewriting every element's state on every canvas
  click, behind the gclass that owns the selection. `7.23.11` gives that
  graph's multi-selection a way in for a finger: both its gestures hang off
  Shift, so Shift becomes a **mode** in the edit toolbar (a tap picks, a
  canvas drag is the band, panning stands aside while it is on) — a button
  rather than a heuristic, because a gesture cannot be two things and the
  toolbar has to say which one the graph is listening for. New consumer i18n
  key `selection mode`, added in the five locale dirs. `7.23.12` makes that
  toggle look PRESSED instead of wearing one of the toolbar's palette colours
  (new `set_pressed_state()`): each of those names a KIND of action, so a
  STATE painted with one of them put undo/redo's violet on the button next to
  undo. `7.23.13` makes that graph's two SELECTS speak the app's language:
  they rendered their raw names (`reading`, `edition`, `dagre`) in every
  language because neither went through `t()` at all — and translating an
  `<option>` means setting its `value` explicitly, or the FSM is told to enter
  a mode called *"Edición"*. `7.23.14` is the one that lets a FINGER move a
  node at all, and both halves are worth knowing: G6 puts `touch-action:
  none` on its canvas and **nothing** on its HTML nodes, so a drag that began
  on a card was a page scroll (two `pointermove`s, then `pointercancel`, the
  node stopped ~20px in); and a long press fired on a TIMER cannot arbitrate
  a gesture, because when it fires the gesture is not over — the press is
  decided at the RELEASE now (moved → drag, still and quick → the element's
  action, still and held → the menu), with the same 10px slop G6 gives its
  own `dragstartDistanceThreshold`. A third thing hid behind those two:
  `click` is not taken from the DOM either — `@antv/g`'s `onPointerUp`
  synthesises one — so swallowing the DOM click never stopped the element's
  own action. `7.23.15` gives that press its only feedback: a 15ms haptic
  tick at the 500ms mark, a NOTICE rather than the decision — a finger that
  buzzes and then carries the node away still gets its drag. `7.23.16` is a
  one-line CSS fix with a lesson worth keeping: **a `yui_toolbar()` alone in
  its row cannot show this class of bug.** Nothing competes for the width, so
  nothing shrinks — and the moment a pinned control shares the row, the
  toolbar's `min-width: auto` (its CONTENT minimum, because its sections are
  `flex-shrink: 0`) makes it refuse to shrink, so it is pushed out of the row
  and the arrow at its right edge lands outside whatever clips the view. Test
  a shared row, not a bare one.

  Three framework contracts came out of that round and are worth knowing
  before writing any gclass that hosts a child:

  - **`gobj_destroy()` destroys the children BEFORE calling `mt_destroy()`.**
    A child torn down in `mt_destroy` is torn down after the framework already
    destroyed it — while it was still running. Retire a hosted child in
    **`mt_stop`**.
  - **`C_YUI_WINDOW.close_window()` calls `on_close` and THEN destroys
    itself.** A host that also destroys the window destroys it twice. On the
    ✕ path the host drops its reference and keeps its hands off.
  - **Adding an output event to a gclass that is hosted as a CHILD is a
    BREAKING change.** The child subscribes its host to everything it
    publishes, so a new output event is a new mandatory declaration in every
    host's FSM — or every host answers *"Event NOT DEFINED in state"*.
  **This submodule tracks `main`/v2.** Its consumers — the in-repo yunos
  **`yunos/js/gui_agent`** and **`yunos/js/gui_treedb`**, wattyzer, yunovatios
  — all pull it from the **npm registry**, and all import it by package
  specifier (`@yuneta/gobj-ui/src/*.js`, resolved by v2's exports map
  `"./src/*"`; the source lives under `src/` mirroring v1, with `index.js` and
  the vite plugin at the package root, and the published tarball ships all of
  it). **All new gobj-ui work lands on `main`/v2**: edit `kernel/js/gobj-ui`
  directly, commit on `main` in the standalone repo, `npm publish`, then bump
  this submodule pointer in yunetas **and the range in each consumer**.
- **`v1` branch** (latest tag `1.0.4`, npm dist-tag `legacy`) — the **frozen**
  legacy GClass GUI stack (`C_YUI_MAIN/WINDOW/TABS/ROUTING` + TreeDB editors +
  charts/maps). `v1` is **maintenance-only** — do not land feature work here.
  estadodelaire and hidraulia (`^1.0.4`) consume it from the **npm
  registry** (the **published** package, **not** this local checkout) — check
  the consumer's own `package.json` rather than assuming they match. The local
  `kernel/js/gobj-ui` checkout is **no longer `v1`** — do not point a
  dependency at it for a v1 consumer. (The in-repo
  `yunos/js/gui_treedb` was migrated to **v2** — it is **not** a v1 consumer.)

**A local edit under `kernel/js/**` reaches no app until it is published.**
That is the whole point of the registry model, and it is the thing to remember
when a change "does not show up": publish the library, then raise the range in
the consumer. Dropping the `file:` symlinks also let every consumer drop
`resolve.preserveSymlinks`, which had been suppressing tree-shaking of the
`index.js` barrel (in wattyzer it pulled all of `maplibre-gl` into the bundle
for an app with no map — 1.5 MB).

`@yuneta/gobj-js` now lives in its **own repository** `github.com/artgins/gobj-js`
(public, snapshot start — history not preserved; single line on `main`, symmetric
with gobj-ui) and is embedded here as the `kernel/js/gobj-js` submodule. It is
versioned to track `YUNETA_VERSION` (SDK `7.26.9`, package **`7.26.19`** on npm since 2026-10-11)
and **published to npm**. It had drifted to 7.13.x while the SDK was at 7.16.2
— a number that told a consumer nothing about which SDK it was built against —
and jumped to `7.16.0` on 2026-08-28 to say it again; 7.14 and 7.15 were skipped
deliberately, because the number does not count releases of the package, it
names the SDK the package belongs to. What moved it along the way: 7.10.0 for
the `gobj_post_event()` alignment; 7.12.0 where `gclass_find_by_name()` /
`gobj_find_service()` stopped answering `undefined` for a lookup that finds
nothing — a `=== null` guard against them was always false; 7.13.3-7.13.5 for
the boolean/int trap, where a JS boolean was read with the C runtime's `=== 0`
guard (a subscription filter that never filtered, plus four more sites of the
same shape); and **7.16.1**, where an inter-event **addressed to a service that
no longer exists** stopped being published to every subscriber of the transport
and started being dropped with a warning. That last one matters to any SPA: a
destroyed view's in-flight frames were landing on the application gobj's
catch-all subscription and raising *"Event NOT DEFINED in state"* once per
frame. **The C side carries the identical open TODO and was left alone**, so a
C client and a JS client still differ on what they do with an orphaned addressed
event.
To ship a new version: edit `kernel/js/gobj-js` directly, bump its `package.json`
in lockstep with `YUNETA_VERSION`, commit on `main` in the standalone repo +
`npm publish`, then **bump this submodule pointer in yunetas**. (A gobj-js-only
patch may move ahead of `YUNETA_VERSION` between SDK releases — e.g. `7.6.7`'s
`EV_ON_CLOSE`-on-deliberate-stop fix shipped on gobj-js first; the SDK then
caught up at the `7.6.7` release.) **Every** consumer takes it from the
registry — estadodelaire, hidraulia, wattyzer, yunomusica, the three yunovatios
GUIs and the in-repo `yunos/js/*` yunos — but they do **not** all declare the
same floor, and assuming they do has been wrong since the in-repo yunos moved
ahead: `yunos/js/*` and the yunovatios GUIs are on `^7.26.19` (2026-10-11),
wattyzer's gui on `^7.26.19` (2026-10-11). Check the consumer's own
`package.json` rather than this line. Note gobj-js publishes **only `dist/`** (`files: ["dist/"]`),
unlike gobj-ui — so a consumer resolves it to the bundle and cannot import its
`src/`.

The JS GUI scaffold (declarative-shell yuno template) lives under
`wattyzer/templates/js_gui/` (see the JS GUI scaffold note below).

Operational notes:
- The vitest suite + active source live here now (v2/`main`):
  `cd kernel/js/gobj-ui && npm install && npm test` (and `npm run build`). Run
  this when touching `kernel/js/gobj-ui/**`.
- `dist/` is no longer on any consumer's critical path: the v2 consumers import
  source files by specifier (`src/` ships in the published tarball too, so
  wattyzer does the same from the registry), and estadodelaire/hidraulia (v1)
  pull `dist/` from the **published** npm tarball. Still run `npm run build` to
  validate, and before publishing.
- To ship a new **v1** release for estadodelaire/hidraulia: from a **v1**
  checkout of the standalone repo, `npm run build` + `npm publish`, then bump
  their `@yuneta/gobj-ui` `^1.x` range. (This local submodule stays on `main`.)
- gobj-ui installs its own copies of shared third-party libs (i18next,
  @antv/g6, maplibre-gl, tabulator-tables, tom-select, uplot,
  vanilla-jsoneditor). Any consumer that also depends on one of them must add
  `resolve.dedupe` for the full list in its `vite.config.js` (mirror
  wattyzer's, the reference v2 consumer) — otherwise components importing a
  module-level singleton (e.g. i18next's `t`) bind an uninitialized second
  copy and render blank.
- Preserve backwards compatibility for existing consumers (estadodelaire +
  hidraulia on published v1, wattyzer, yunovatios and the in-repo yunos on
  published v2) and update the repo's `README.md` when the contract
  changes.

## ⚠️ CRITICAL: Memory allocation in C code

**NEVER use raw `malloc` / `calloc` / `realloc` / `free` / `strdup` / `strndup` in Yuneta C code.**
**ALWAYS use the `gbmem_*` wrappers declared in `kernel/c/gobj-c/src/gbmem.h`.**

Applies to every file under `kernel/c/`, `modules/c/`, `utils/c/`, `yunos/c/`, `tests/c/`,
`performance/c/`, `stress/c/` — any C code linking against gobj-c.

| Forbidden          | Use instead                  |
|--------------------|------------------------------|
| `malloc(n)`        | `gbmem_malloc(n)`            |
| `calloc(n, sz)`    | `gbmem_calloc(n, sz)`        |
| `realloc(p, n)`    | `gbmem_realloc(p, n)`        |
| `free(p)`          | `gbmem_free(p)`              |
| `strdup(s)`        | `gbmem_strdup(s)`            |
| `strndup(s, n)`    | `gbmem_strndup(s, n)`        |

**Why:** Yuneta's allocator is swapped at startup via `gbmem_set_allocators()` and
`json_set_alloc_funcs()`. Mixing libc with `gbmem_*` causes pointers allocated by one
to be freed by the other — heap corruption or leaks that bypass `CONFIG_DEBUG_TRACK_MEMORY`.
Memory tracking, size limits (`MEM_MAX_BLOCK`, `MEM_MAX_SYSTEM_MEMORY`) and the
superblock pool only work if every allocation goes through `gbmem_*`. Jansson is
already routed through `gbmem_*`, so all `json_*` APIs are safe.

**Exception:** third-party code in `kernel/c/linux-ext-libs/` (OpenSSL, liburing, …)
uses its own allocator.

When reviewing diffs or writing code, grep for `\bmalloc\s*\(`, `\bfree\s*\(`,
`\brealloc\s*\(`, `\bstrdup\s*\(` and replace each hit.

## ⚠️ CRITICAL: Always braces, never single-line bodies

**Every `if` / `else` / `else if` / `for` / `while` / `do` body must be wrapped in
`{ ... }` across multiple lines.** Applies to every language in the repo (C, JS,
Python, shell), every block, no matter how short.

| Forbidden                           | Required                                  |
|-------------------------------------|-------------------------------------------|
| `if(!ptr) return;`                  | `if(!ptr) {`<br>`    return;`<br>`}`      |
| `if(x) doIt();`                     | `if(x) {`<br>`    doIt();`<br>`}`         |
| `for(...) continue;`                | `for(...) {`<br>`    continue;`<br>`}`    |
| `if(x) y(); else z();`              | `if(x) {`<br>`    y();`<br>`} else {`<br>`    z();`<br>`}` |
| `if(x) { y(); }` (one-liner)        | `if(x) {`<br>`    y();`<br>`}`            |

**Why:** code is reviewed visually. A `return` / `continue` / `break` / `throw`
hidden at the end of a single-line `if` disappears into the condition. Important
control-flow deserves a braced body so the eye lands on it when scanning.

When reviewing or writing code, search for and fix:

- `\)\s*(return|continue|break|throw)` followed by anything other than `{`.
- `\)\s*[a-zA-Z_$].*[^{]\s*$` — non-brace statement after a control-flow `)`.
- `\}\s*else\s+(?!if|\{)` — `else` whose body starts with code.
- `\)\s*\{[^}]*\}\s*$` — one-liner braced body. Expand it.

Verbose by design. Visibility wins.

## ⚠️ CRITICAL: No silent errors

**Every error path must leave a trace** — either `gobj_log_error(...)` at the
failure point, or the annotation `// Error already logged` when the failing
helper already logged. Applies to every failure exit: `return -1/NULL/FALSE`,
`continue`/`break`/`goto` after a failed validation, swallowed truncation,
void-function early returns, `default:` on an unexpected enum. Never "fix" a
compiler warning (`-Wformat-truncation`, `-Wunused-result`) with a silent
early-out.

**Why:** the log is the only window into runtime failures; a silent error path
defeats the framework's observability.

Corollaries:

- **Never add a no-op FSM action to swallow *"Event NOT DEFINED in state"*.**
  The error is deliberately loud — it flags a badly built FSM or a sender
  emitting in the wrong situation. Trace who sends the event in that state and
  fix that path.
- **Never debug a running yuno with `fprintf(stderr, …)`** or ad-hoc `/tmp` log
  files. Use the trace machinery (`set-global-trace` / `set-gclass-trace`,
  `gobj_trace_msg/json/dump`) — it has scoping, standard JSON format and
  cross-yuno correlation. If no existing category fits, add a per-gclass trace
  level (16 slots) rather than bypassing the framework. Playbook:
  [`DEBUGGING.md`](yunos/c/yuno_agent/DEBUGGING.md). Pair every trace enable
  with its disable in the same session.
- **Protocol decoder severity** (`c_prot_*`, `c_websocket`, mqtt, …): a failure
  caused by a malformed/malicious **peer packet** ⇒ `gobj_log_warning` with its
  assigned category (`MSGSET_PROTOCOL`, `MSGSET_MQTT`, …), **no**
  `LOG_OPT_TRACE_STACK`, plus a length-capped dump of the offending bytes. A
  broken **internal invariant** ⇒ `gobj_log_error` with stack trace. Ask:
  "could a remote peer trigger this with bad bytes?" → warning; "our own
  contract is broken" → error.
  **Json received from a peer is parsed with `gbuf2json_from_peer(gobj,
  gbuf, channel_gobj)`**, which does exactly that (warning, `peername`,
  capped dump, no stack). `gbuf2json(gbuf, 2)` is for json WE wrote: on a
  frame from outside it logs an ERROR with a stack and the whole buffer, and
  names no peer.

## C coding rules

- **Paths:** assemble filesystem paths with `build_path(bf, sizeof(bf), seg1,
  ..., NULL)` (`kernel/c/gobj-c/src/helpers.h`), never `snprintf("%s/%s")`.
  `build_path` strips duplicate `/`, always null-terminates and logs on
  overflow. Applies to new/modified code; don't refactor pre-existing calls
  unprompted.
- **Buffer sizes:** use the `<limits.h>` system constants — `NAME_MAX`
  (filenames), `PATH_MAX` (full paths), `HOST_NAME_MAX`, `LOGIN_NAME_MAX`,
  `PIPE_BUF` — never hand-picked numbers. The constant documents the contract;
  `char buf[64]` lies about it.
- **Timeouts:** time them with the helpers — `start_sectimer` / `test_sectimer`
  and `start_msectimer` / `test_msectimer` (`kernel/c/gobj-c/src/helpers.h`) —
  never with hand-rolled `time()` / `clock_gettime()` arithmetic. Timeouts are a
  subsystem that has to stay under control, and low-level constructs hide them:
  `start_msectimer(priv->timeout_x)` + `test_msectimer(v)` names the timer at
  both ends and greps, while a subtraction of two `time_t`s buried in an `if`
  is invisible to anyone reviewing what times out where. **Prefer the
  `msectimer` pair: it runs on `CLOCK_MONOTONIC`**, so a timer survives an NTP
  step — and the wall clock jumps exactly when it hurts most, at boot. The
  `sectimer` pair reads `time()`, the WALL clock (checked 2026-09-24; this line
  used to claim both were monotonic), so a clock set back holds a sectimer that
  long and a clock set forward fires it early.
- **Naming:** name functions by intent — verb + object + direction
  (`get_token_from_idp`, `send_token_to_browser`) — not by framework role; the
  framework keys (`mt_*`, `ac_*`, `exec_action`) already label the role. Prefer
  generic domain words over vendor names (`idp`, not `keycloak`). If a
  function's honest name is uninformative, the function is probably wrong —
  remove it, don't rename it.
- **Comments:** asymmetric by location. File headers may carry ample prose
  (gclass role, FSM, design decisions). Inside function bodies default to *no
  comment* — one short line only for a genuinely non-obvious why (hidden
  constraint, workaround, invariant). Never narrate what the next line does.
- **Section titles** in scripts (bash, Python) use the established 3-line
  comment block (`#` / `#   Title` / `#`); never invent one-liner formats
  (`#---`, `#===`) — they are invisible when scanning.
- **Copyright:** on non-trivial edits bump the ArtGins year as a **range**
  (`Copyright (c) 2024-2026, ArtGins.`), keeping the original year. Brand-new
  files use a single year. `Niyamaka` copyrights are historical — never change
  them.
- **Persistent attrs:** always name what you save —
  `gobj_save_persistent_attrs(gobj, "attr_name")` (string / list / dict of
  names). The bare call saves every `SDF_PERSIST` attr, which is wasteful and
  can clobber attrs the caller didn't touch. Same API shape in C and JS.
  **Only a SERVICE can load or save them** — `gobj_load/save/remove_
  persistent_attrs()` all refuse a gobj that is not one and log *"Only gobj
  services can load/save writable-persistent"*. So an `SDF_PERSIST` attr is
  also a statement about how the gclass must be CREATED: a gclass that
  persists anything cannot be hosted with `gobj_create_pure_child()`. In JS
  that is what `yui_mount_service_view()` is for, and it is the same
  constraint as the `src` of an inter-yuno answer (see *Inter-yuno
  communication* above) — a view that talks to a backend has to be a service
  twice over, and only the persistence half says so out loud.
- **Command responses:** every non-empty `jn_comment` passed to
  `build_command_response()` in a `cmd_*` handler starts with the yuno
  identity: `json_sprintf("%s: ...", gobj_yuno_role_plus_name(), ...)`. Keep it
  on one line if the whole expression fits in 80 bytes, else break with the
  format string first. Skip when `jn_comment == 0` or when the message already
  names a specific target gobj.
- **`SDF_RSTATS` attrs backed by a priv field need `mt_reading`** in the
  GMETHODS — `priv->counter++` alone leaves the attr at zero forever (typed
  readers consult `mt_reading` first; the stored attr is never written). Keep
  attr name and priv field name identical.
- **Commands callable via `ycommand command-yuno`** hit two parser quirks:
  (1) **`command-yuno` passes its WHOLE kw as the filter that selects the
  yuno** (`gobj_list_nodes(..., kw, ...)` in `cmd_command_yuno`), so **any**
  parameter named like a field of the yuno record silently becomes a filter on
  that field and the command answers *"Yuno not found"* — naming the yuno,
  never the parameter. `id` is only the best-known case: `date`, `global`,
  `binary`, `configurations`, `traced` and the rest of the `list-yunos`
  columns collide the same way. Check those columns before naming a parameter,
  and prefix yours (`report_date`, `<thing>_id`), keeping the bare name as a
  fallback the handler reads for direct callers.
  (2) `SDF_REQUIRED` + `DTP_JSON`/`DTP_INTEGER` parameters are
  forwarded from kw **without** type coercion — drop `SDF_REQUIRED` and
  validate in the handler, or defensively accept the string form
  (`anystring2json`).
- **`gobj_log_last_message()`** is a process-global 256-byte buffer written
  only by `priority <= LOG_ERR` logs. Prefer explicit `json_sprintf` messages
  in `cmd_*` failure paths; never rely on it across function boundaries. A
  kernel function that fails via `gobj_log_info` should call
  `gobj_log_set_last_message()` itself if callers need the cause.

### C API footguns

- **`gclass_create()` with a NULL `attrs_table` segfaults at the first
  instance.** A gclass with no attributes still needs a table holding
  `SDATA_END()`. Passing `0` compiles and registers fine, and then
  `gobj_sdata_create()` (`gobj.c`) walks it with `while(it->name != 0)` and no
  NULL guard, so the crash lands on the first `gobj_create` of that gclass —
  far from the call that caused it, and with a stack that blames the framework.
  Every gclass in the tree passes a real table. Keep it that way, especially in
  the throwaway driver gclasses that tests define.

- **A json as a TABLE: `json2flat()` / `flat2json()`** (`kwid.h`, and the same
  grammar in `gobj-js`'s `helpers.js`). One row per leaf, the id being the path
  of the item — the form to store, to compare and to diff, and the only one a
  person reads when two configurations disagree. Ids are joined by `` ` ``, a
  literal backtick in a key is DOUBLED (so no key is forbidden), an array index
  is `[N]`, a key starting with `[` doubles it, and **an empty container is a
  leaf** — `{}` and `[]` have no leaves of their own and `"properties": {}` is
  everywhere. `flat2json()` refuses instead of guessing when an id is a leaf
  and a container at once, because the answer would depend on the order the ids
  are read in. `flat_diff()` / `flat_apply()` work on the flat form, where an
  id addresses one value. **The two grammars must stay identical**: a flat json
  is written by one side and read by the other. The old `json_flatten_dict()` /
  `json_unflatten_dict()` are deprecated aliases — their first implementation
  turned a dict keyed by a yuno id into an array of 1631 elements.

- **`kw_get_dict()` / `kw_get_list()` TAKE OWNERSHIP of `default_value`.** They
  `JSON_DECREF(default_value)` on the path where they **find** the key — the
  path that always runs — so handing them a borrowed pointer as the fallback
  spends one reference per call and logs *"BAD json_decref()"*. It reads like
  the harmless idiom it resembles (`kw_get_str`'s default is a plain `const
  char *`, owned by nobody), and it only bites once the key is actually
  present, so it survives every test that exercises the missing-key path. Ask
  without a default and choose afterwards:
  `json_t *v = kw_get_dict(gobj, kw, key, 0, 0); if(!v) { v = fallback; }`.
  Real bug: a gate handing its own compiled-in model config as the fallback for
  a per-device one, one bad decref per frame received.
  **With `KW_CREATE` the answer is what the kw holds, or NULL** (7.26.9+): a
  write that fails (an empty path or segment, a scalar on the path), a path
  that holds another type, or a NULL kw is logged, spends the default and
  returns NULL. The readers (`kw_get_dict/list/dict_value`,
  `kw_get_subdict_value`) used to hand back the FREED default, or an orphan
  nobody freed. Check the answer of a create whose path comes from data --
  and a name that comes from data is a KEY: `json_object_get/set`, not a path
  reader (a backtick in it splits it).

- **A name that comes from data is a KEY, never a path.** The `kw_*` readers
  and writers split their path at the delimiter (`` ` ``), so an id, a pkey or
  pkey2 value, a message key, a client id, a topic filter, a username -- any
  value a user or a peer chooses -- read as a path addresses the wrong place
  the day it holds a backtick: a treedb id ``k`b`` put its instances INSIDE
  the index entry of `k`, an MQTT unsubscribe of ``a`b`` left the client
  subscribed. Such a value goes to `json_object_get/set(_new)/del`. A kw path
  is for a literal, a schema/attribute name defined in code, or a path the
  code assembles on purpose; when a variable path is a path on purpose, say so
  in a comment. Real bugs found in one sweep (2026-10-11): `tr_msg`,
  `tr_treedb` (pkey2 index, `delete_node`), `tr_msg2db`, `c_prot_mqtt`,
  `logcenter`.

- **An absent `DTP_JSON` is `json_null()`, not C NULL — so `if(!jn)` is dead
  code.** `set_default()` materialises a `DTP_JSON` with an empty default as
  a json null, a valid pointer, and that covers every attr declared that way
  plus everything `gobj_sdata_create()` builds — including a subscription,
  whose optional `__config__`/`__global__`/`__local__`/`__filter__` are all
  of them. Ask the question instead, with the two `static inline` in
  `helpers.h`: **`empty_json(jn)`** — true for C NULL, `json_null`, `{}`,
  `[]`, `""` and any scalar — which is the one you want almost always,
  because an empty filter is as much "no filter" as an absent one; and
  **`json_absent(jn)`** — true only for C NULL and `json_null` — where null
  and empty mean different things. When the shape is the contract, test the
  shape (`json_is_array(jn) && json_array_size(jn)>0`); do not reach for a
  reader that has the type you want, because `kw_get_dict()` on a
  list-shaped `__filter__` returns NULL and drops the filter.
  The three readers disagree and that is why the error never lands on the
  cause: `kw_get_dict()`/`kw_get_list()` reject a null by type (and log with
  `KW_REQUIRED`) -- WITHOUT `KW_CREATE`; with it (7.26.9+) a null is "no value"
  and the create replaces it --, `kw_get_dict_value()` **returns the null** and stays quiet
  even with `KW_REQUIRED`, and a path read treats a null segment as
  not-found. **In `gobj-js` there is no trap** — a null default is
  javascript `null`, falsy — so a port across the two runtimes gets this
  wrong in both directions; `empty_json()`/`json_size()` exist there with the
  same truth table (`json_absent()`'s twin is `is_null()`). Real bugs: the
  cert-reload guard of `c_tcp_s`/`c_udp_s`, `ycli`'s shortkeys dict (whose
  dead guard was masking a double free of a borrowed attr), and a first shot
  per device answered for a subscription that carried no filter.
  **And the rule that prevents the next one: if you mean a dict, declare
  `DTP_DICT`; if you mean a list, `DTP_LIST`.** `DTP_JSON` means *any* json,
  null included, and `json2item()` proves the difference — it refuses a
  non-object for a `DTP_DICT` **with a log**, while it accepts anything at
  all for a `DTP_JSON`. A type that lies is what makes the guard necessary;
  `crypto` was `DTP_JSON` in seven gclasses and is a dict in all seven.

- **`jwt_checker_verify2()` fails closed** (`kernel/c/libjwt`, since 7.6.0,
  `46f62d8be`): a non-NULL return is the claims of a token that passed every
  check, and it is YOURS to `json_decref`; on any failure it returns `NULL`
  with the cause in `jwt_checker_error(checker)` /
  `jwt_checker_error_msg()`. (It used to return the claims of a failed token
  too, with the verdict only in the checker -- a caller that looked at the
  payload accepted forged tokens.) Canonical consumer: `c_authz.c`.
- **`kw["gbuffer"]` is auto-decref'd** by gobj's serializer table when the kw
  is decref'd. Reading the pointer with `extract=FALSE` and then calling
  `GBUFFER_DECREF` before `KW_DECREF` is a double-free (*"BAD gbuf_decref()"*).
  Decref manually only after `extract=TRUE` (ownership transferred) or when
  the gbuffer never entered a kw. Match `c_prot_raw` / `c_prot_tcp4h` in new
  protocol gclasses.
- **A kw is refcounted with `kw_incref`/`kw_decref`, never with
  `json_incref`/`json_decref`.** Events always travel with a kw, so this covers
  every `gobj_send_event` / `gobj_publish_event` you hand an extra reference to.
  The pair is not a synonym of the json one: `kw_decref()` drops the serialized
  binary fields (the gbuffer) on **every** call, not just the last, and
  `kw_incref()` is what balances that. A `json_incref(kw)` therefore bumps the
  json refcount while leaving the gbuffer's untouched, and every `KW_DECREF`
  downstream decrefs a gbuffer that was never increfed — a double free
  (*"BAD gbuf_decref()"*). It bites only once the kw actually carries a gbuffer,
  so the wrong call sits there looking fine for years: write `kw_incref` even
  when today's kw is plain JSON. Two real bugs from this: `c_task`'s lmethod
  forward, and `c_iogate`'s `send_all()` (fine with one open channel, double
  free from the second on).
  **Fix the pair, never one half.** `json_incref(kw)` + `JSON_DECREF(kw)` in the
  same function is two errors that cancel out; correcting only the incref turns
  a wrong-but-balanced ledger into a gbuffer **leak**, and correcting only the
  decref turns it into a double free. When you touch one side, check the other
  in the same function — including where the kw is handed off owned
  (`msg_iev_build_response()` releases it with `KW_DECREF`, so a `cmd_*` that
  ends there is already balanced).

## ⚠️ Documentation language: English only

**Every text artifact committed to this repo must be in English**, regardless of the
chat language (often Spanish). Applies equally to claudia console (Claude Code CLI)
and claudia gui (claude.ai web).

In English (always): `.md` files (READMEs, design docs, this `CLAUDE.md`,
in-tree guides, skill outputs), section headings inside those files, code comments,
commit messages, PR titles/descriptions, log messages, error strings.

What stays in the user's language: the live chat between user and assistant.
Only artifacts that get committed must be in English.

When editing a doc with Spanish content, translate it directly when the user asks
for any cleanup of that file; otherwise propose the translation first.

### Documentation conventions

- **Reviewing or debugging a yuno/gclass = improve its doc page in the same
  pass.** The verified mental model just built (config attrs, commands,
  persistence, failure semantics, trace levels) is exactly what its page under
  `docs/doc.yuneta.io/` should capture — many pages are stubs.
- **Topology diagrams are left-to-right following data flow** (sources →
  gateways → DBs → consumers; agent/utilities as a bottom band), realms as
  horizontal background bands, `role^name` node labels. No top-down layouts.
- **Documentation without examples is bad documentation.** Every schema key,
  parameter, command or rule you document gets an example of how to WRITE it,
  not only what it means — in the form the reader copies (a treedb schema key
  as a C schema literal, like the ones under `src/`), minimal, and verified
  against the code that enforces it. **Why:** `main_topic` was described in
  three places and shown in none; the only example was a test schema that
  sets every attribute at once. In a docs audit, "no example" is a finding.

### Documentation site (doc.yuneta.io)

The site is built with **mystmd** (MyST / Jupyter Book 2), deployed via
`docs/doc.yuneta.io/deploy.sh`. Tooling quirks:

- `myst build --html` does not exit — it boots a dev server, and it writes no
  `_build/html`: it is a **validation** step (`deploy.sh` does the real build).
  Validate edits non-interactively with
  `NODE_OPTIONS=--disable-warning=DEP0169 timeout 30 myst build --html 2>&1 |
  grep -iE 'warning|error|⚠|fail'`. Without that `NODE_OPTIONS` the grep always
  hits: on node 24 the book-theme's own bundle calls `url.parse()` while
  rendering and node prints DEP0169 on every build. It is silenced by code, not
  with `--no-deprecation`, so any other deprecation still shows. `deploy.sh`
  exports the same thing.
  MyST warning lines start with `⚠`, not with the word "warning" — include `⚠`
  in any grep of build logs.
  **`timeout` is the whole cleanup**: it terminates the server (exit 124) and
  leaves no straggler (verified — `pgrep -x node` back to its prior count).
  **Do NOT chain `pkill -f "myst build"` after it**, as this file used to say:
  `pkill -f` matches full command **lines**, and the line running the build
  contains that literal, so the pkill matches its own shell and kills it — the
  command dies with no output (exit 144) and the grep result is lost. The
  `[m]yst` bracket trick does NOT save it either: the bracket only stops the
  *pattern* from matching itself, while the `timeout 30 myst build …` earlier
  in the same line still matches. If a straggler ever does survive, kill it
  from a **separate** call, or copy `deploy.sh`'s approach (snapshot
  `pgrep -x node` before/after, kill only the new PIDs).
- After any **bulk** edit of `docs/doc.yuneta.io/**.md` (e.g. a release-link
  repin), run `rm -rf _build/site _build/html` before `deploy.sh` — myst reuses
  a stale AST cache and ships old content (keep `_build/templates`, the theme
  cache). Verify with a grep of `_build/html` for the old pattern (also catches
  the `build/*.md` source-copy artifacts).

## Onboarding docs (read these before touching code)

If you are a Claude instance landing in this repo for the first time, read
these in order. They are ~5800 lines total but they replace ~weeks of
spelunking through `c_agent.c` and `gobj.c`. The README is an index.

| # | Doc                                                                     | Covers                                                            |
|---|-------------------------------------------------------------------------|-------------------------------------------------------------------|
| 0 | [`yunos/c/yuno_agent/README.md`](yunos/c/yuno_agent/README.md)          | Index of the chapters below.                                      |
| 1 | [`ENTRY_POINT.md`](yunos/c/yuno_agent/ENTRY_POINT.md)                   | What `main()` does. `yuneta_entry_point` + `ydaemon.c` watcher (the autonomous-survival kernel). `/var/crash/core.%e`. |
| 2 | [`YUNO_LIFECYCLE.md`](yunos/c/yuno_agent/YUNO_LIFECYCLE.md)             | How the agent manages yunos (create/run/kill/update/delete).      |
| 3 | [`DEBUGGING.md`](yunos/c/yuno_agent/DEBUGGING.md)                       | Trace levels, log infrastructure, logcenter, SPA dev panel.       |
| 4 | [`IPC.md`](yunos/c/yuno_agent/IPC.md)                                   | Events, ievents, gates, the SPA case.                             |
| 5 | [`REALMS.md`](yunos/c/yuno_agent/REALMS.md)                             | Multi-tenancy, on-disk layout, CRUD, what is NOT realm-scoped.    |
| 6 | [`SCAFFOLDING.md`](yunos/c/yuno_agent/SCAFFOLDING.md)                   | `yuno-skeleton` templates, banner conventions.                    |
| 7 | [`YUNO_AUTH.md`](yunos/c/yuno_agent/YUNO_AUTH.md)                       | OIDC/auth_bff, `C_AUTHZ`, cert-sync. **Read §4.5**: the per-command authz check is re-armed but gated off by default (`enable_command_authz`). |
| 8 | [`GOBJ.md`](yunos/c/yuno_agent/GOBJ.md)                                 | Framework crash course (gclass, mt_*, SData, runtime tree).       |
| 9 | [`YUNO_TREEDB.md`](yunos/c/yuno_agent/YUNO_TREEDB.md)                   | timeranger2 + treedb (link-saves-child rule, `topic_version` trap). |

Each one is published as a chapter under **Operating Yuneta** in
[`docs/doc.yuneta.io`](docs/doc.yuneta.io/) (slug `/entry-point`,
`/yuno-lifecycle`, `/debugging`, `/ipc`, `/realms`, `/scaffolding`,
`/yuno-auth`, `/gobj`, `/yuno-treedb`).

**Wondering how the pieces fit above one gobj?** Read
[`docs/doc.yuneta.io/philosophy/architecture.md`](docs/doc.yuneta.io/philosophy/architecture.md)
(published at [`/architecture`](https://doc.yuneta.io/architecture)) — the
three layers, the one message mechanism, and why a gclass name is a role. It is
the conceptual companion to `GOBJ.md` (#8) and `IPC.md` (#4), summarised under
[Architecture](#architecture) below.

**Deploying/updating yunos on a node?** Read
[`docs/doc.yuneta.io/deploying-yunos.md`](docs/doc.yuneta.io/deploying-yunos.md)
(published at `/deploying-yunos`) — the scenario-driven guide (hot-patch,
version bump, config-only, new yuno, rollback) tying together the `yunetas`
CLI, `sync_binaries.py` and `sync_configs.py`. Start there, not in
`c_agent.c`. Keep that guide updated when deploy flows change.

## System Prerequisites

See [`docs/doc.yuneta.io/installation.md`](docs/doc.yuneta.io/installation.md) for
the full apt dependency list and one-time environment setup.

Avoid **snap-packaged CLI tools** for anything the build or docs need (typst,
pandoc, formatters): snap confinement only grants `$HOME`, so they cannot read
`/yuneta/...` (symptom: *"input file not found"* for a file that exists, with
`which <tool>` → `/snap/bin/...`). Install via apt or a static binary in
`/usr/local/bin` (which shadows `/snap/bin` in `PATH`).

## Quick Start

```bash
# 1. Install the yunetas CLI tool
pipx install yunetas

# 2. Generate .config (compiler, build type, modules)
menuconfig

# 3. Set up environment (source from within the repo)
source yunetas-env.sh

# 4. Apply compiler selection to external libs (sudo: update-alternatives)
./set_compiler.sh

# 5. Build external dependency libraries
cd kernel/c/linux-ext-libs && ./extrae.sh && ./configure-libs.sh && cd ../../..

# 6. Initialize build dirs and generate headers
yunetas init

# 7. Build everything
yunetas build

# 8. Run tests
yunetas test
```

## Build Commands

```bash
source yunetas-env.sh                                  # set env (from repo dir)
yunetas init                                           # FIRST TIME or after .config change
yunetas build                                          # build + install in dependency order
yunetas test                                           # run all tests
yunetas clean                                          # clean build artifacts

ctest -R test_c_timer --output-on-failure --test-dir build   # run a single test
./ctest-loop.sh                                              # run ctest in a loop until first failure
```

**Build via the `yunetas` CLI, not per-module make.** For any kernel/lib change
that must reach consumers, and for ANY version bump or release deploy, do a
CLEAN rebuild: `yunetas clean && yunetas build`. Incremental builds have left
stale binaries in `outputs/yunos/` — always verify the staged binary
(`outputs/yunos/<role> --print-role`) before deploying it.

**A TEST BINARY LINKS THE INSTALLED LIBRARIES**, from `outputs/lib`, not the
ones in the build tree. Two consequences that cost hours on 2026-08-28: a fix in
kernel source does not reach a test until `yunetas build` **installs** it (so
`cmake --build build --target <test>` can keep running the old library), and
configuring a build with `-fsanitize=address` instruments the test's own `.c`
files **and nothing else** — ASan then reports nothing while the plain build
keeps aborting, which reads as "not a real bug" and is only "you did not look at
it". To sanitise for real, build the libraries in the ASan tree (configured
with `-DENABLE_SDK=ON`, see below) and **relink the test by hand** against
them; and if the corruption is around json, instrument `jansson` too (with
its own generated config headers, or it parses nothing). The recipe is
written down in `docs/doc.yuneta.io/test_suite.md`.

`ctest --test-dir build` only **runs** tests, it never rebuilds them — the
root `build/` tree is built by `yunetas test` (or `cmake --build build`), not
by `yunetas build`. A raw ctest after per-module builds executes stale
binaries.

**The root `build/` holds the tests, performance and stress, NOT the SDK**
(`ENABLE_SDK` OFF by default, since 2026-10-05). The kernel, modules, utils and
yunos are built and installed by each module's own build dir (`yunetas build`,
and the first step of `yunetas test`). Until then the root tree built them too
and installed a second copy of every library into `outputs/lib` over the first:
each tree replaced the other's copy and its new mtime relinked every executable
of the other tree, on every `yunetas build` and every `yunetas test`, with
nothing changed. `-DENABLE_SDK=ON` makes the root tree self-contained again (an
ASan tree; a checkout with no module build dirs).

**`yunetas test` builds first and runs ctest second, so "is ctest running?" is
not "is it finished?".** Never start a second run while the first is still in
its build phase: ctest then walks a half-built tree and reports the missing
binaries as *"Not Run"* — 118 of 120 in one real case, which reads exactly like
a catastrophic regression and is not one. Wait for the invocation itself to
exit.

**When you wait for one, match the process by NAME, not by command line.**
`pgrep -f "ctest"` (or `-f "cmake --build"`) matches the **shell running the
wait loop**, because its own command line contains the pattern — so the loop
never ends and nothing tells you why. `pgrep -x ctest` matches the executable
name and behaves. This is the same mechanism as the `pkill -f "myst build"`
trap in *Documentation site* above, in its waiting form rather than its killing
one. The `[c]test` bracket trick does not save either of them.

### External projects (registered in the CLI)

Projects (wattyzer, estadodelaire, …) can be registered so the `yunetas`
CLI builds them right after the SDK. Registry: `~/.yuneta/projects.json`
(machine-local user state, outside the source tree).

```bash
yunetas register-project /yuneta/development/projects/wattyzer
yunetas list-projects / unregister-project <name>
yunetas init|build|clean                  # SDK + every registered project
yunetas init|build|clean <name>...        # only those projects (SDK skipped)
yunetas init|build|clean --sdk-only       # only the SDK
yunetas sync-binaries [args]              # bundled tool (yunetas.agent_tools.sync_binaries)
yunetas sync-configs [args]               # auto-match batches/<host>/ to the agent's realm_ids (*list-realms)
yunetas sync-configs --host <h> [args]    # or target one batches dir explicitly
yunetas upgrade-yunos [--no-snap|--snap-name N|-y|-n]  # shoot-snap -> find-new-yunos -> deactivate-snap
```

### Building a single module

For a quick single-module compile sanity check only — never the way to produce
deployable binaries (see the CLI rule above):

```bash
cd kernel/c/gobj-c/build && make install
cd tests/c/c_timer/build && make && ctest --output-on-failure
```

### JavaScript framework (kernel/js/gobj-js/, kernel/js/gobj-ui/)

```bash
cd kernel/js/gobj-js && npm install && npm run build && npm test
# gobj-ui submodule now tracks main/v2 — full vitest suite + build live here:
cd kernel/js/gobj-ui && npm install && npm run build && npm test
```

Run this matrix locally when touching `kernel/js/gobj-js/**` or
`kernel/js/gobj-ui/**` (the submodule now tracks main/v2 — see the submodule
note above). There is no GitHub Actions workflow for it — by design,
the only workflow in `.github/` is `release-packages.yml` (builds the AMD64
`.deb` and the x86_64 `.rpm` on a published release).

### When to re-run `yunetas init`

- First time after cloning.
- Any time `.config` changes (compiler, build type, enabled modules).
- After toggling the TLS backend in `.config`, also wipe `CMakeCache.txt` +
  `CMakeFiles/` in the affected `tests/c/*/build/` dirs — their cached
  `MBEDTLS_LIBS`/`OPENSSL_LIBS` go stale and test links fail with undefined
  TLS symbols.

`yunetas init` recreates `outputs/`, regenerates `yuneta_version.h` and
`yuneta_config.h`, and runs `cmake` in each module's `build/`.
`yunetas build` runs `make install` in each module in dependency order.

### Build configuration (`.config`, Kconfig format, gitignored)

Generate via `menuconfig`. Key knobs:

- Compiler: `CONFIG_USE_COMPILER_GCC=y` (default) or CLANG.
- Build type: `CONFIG_BUILD_TYPE_RELWITHDEBINFO=y` (default), Debug, Release, MinSizeRel.
- TLS: `CONFIG_HAVE_OPENSSL=y` and/or `CONFIG_HAVE_MBEDTLS=y` (runtime selectable).
- Static linking: `CONFIG_FULLY_STATIC=y` — fully static glibc binaries.
- Debug: `CONFIG_DEBUG_WITH_BACKTRACE`, `CONFIG_DEBUG_TRACK_MEMORY`, `CONFIG_DEBUG_PRINT_YEV_LOOP_TIMES`.
- Modules: `CONFIG_MODULE_CONSOLE`, `CONFIG_MODULE_MQTT`, `CONFIG_MODULE_POSTGRES`, `CONFIG_MODULE_TEST`.

Changing a flag in `kernel/c/linux-ext-libs/configure-libs.sh` that alters
installed paths, artifact names or exported symbols must land in the **same
commit** as the migration of every consumer (includes +
`target_link_libraries`). Grep all consumers first, list them as companion
changes, and verify with `nm`/`strings` on rebuilt binaries.

**Any change to the scripts of `kernel/c/linux-ext-libs/` bumps `VERSION` in
`configure-libs.sh`** (with its entry in the header), even when the installed
libraries come out identical: the version tracks the scripts, not the
binaries, and the `yunetas` CLI uses it to tell every node that builds from
source to rerun `./extrae.sh && ./configure-libs.sh`.

### Fully Static Binaries (`CONFIG_FULLY_STATIC`)

Produces fully static glibc binaries (GCC or Clang) that run on any Linux of the
same arch with no shared libraries. Works with both TLS backends. Since 7.4.3
the entire yuno set is static (emailsender included — libcurl was dropped in
favour of native SMTPS on top of `ytls` / `c_tcp`).

`configure-libs.sh` already passes the OpenSSL flags needed for clean static
builds (`no-dso`, `no-sock`).

**The prebuilt archives are tied to the glibc that built them.** Never compile
fresh objects against `outputs/lib` + `outputs_ext/lib` taken from a package
built on another distro: the archives reference glibc internals
(`_dl_x86_cpu_features`, backing the ifunc `memcpy`/`strlen` dispatch) whose
layout moves between releases. A dynamic link fails loudly; a **static** link
resolves silently and the binary corrupts the heap at run time — SIGABRT deep
in `unlink_chunk`/`_int_malloc` seconds after start, no framework error first,
stack blaming unrelated code. `tools/cmake/libc_guard.cmake` enforces this at
configure time via `outputs/lib/yuneta_libc.stamp`; if it fires, build off-node
and ship binaries, or build the whole SDK from source on that machine. Do not
reach for `-DYUNETA_ALLOW_LIBC_MISMATCH=ON` — it only silences the message.
The compiler is not the variable and is deliberately unchecked (the same
sources built with clang on the affected node crashed identically).

**Static linking is per-target opt-in:** every yuno/util `CMakeLists.txt`
needs BOTH blocks — (1) the linker flags after `include(project.cmake)`
(`CMAKE_EXE_LINKER_FLAGS "-static ..."`, `CMAKE_FIND_LIBRARY_SUFFIXES ".a"`,
`BUILD_SHARED_LIBS OFF`) and (2) `LINK_SEARCH_START_STATIC` /
`LINK_SEARCH_END_STATIC TRUE` after `add_yuno_executable`. With only the
second, the link succeeds but glibc stays dynamic. Verify:
`ldd outputs/yunos/<name>` → *"not a dynamic executable"*.

**Size vs speed trade-off (AMD64, RelWithDebInfo, static):**
- mbedTLS produces binaries ~3× smaller than OpenSSL (~10 MB vs ~30 MB).
- mbedTLS is ~40% slower for TLS round-trips (`perf_c_tcps`).
- OpenSSL for hot-path servers; mbedTLS when binary size matters (embedded/edge).

**Source-code adjustments for static (all under `#ifdef CONFIG_FULLY_STATIC`):**
NSS and the system resolver are not available in static glibc, so the following
helpers replace them — use these instead of the libc calls anywhere new code is
added:

- `static_getpwuid()` / `static_getpwnam()` / `static_getgrnam()` /
  `static_getgrouplist()` in `kernel/c/gobj-c/src/helpers.{h,c}` — read
  `/etc/passwd` and `/etc/group` directly.
- `yuneta_getaddrinfo()` / `yuneta_freeaddrinfo()` in
  `kernel/c/yev_loop/src/static_resolv.{h,c}` — UDP DNS resolver reading
  `/etc/resolv.conf` + `/etc/hosts`. The header `#define`s redirect
  `getaddrinfo`/`freeaddrinfo` for files that include it.

## Architecture

**The model these rules implement** is written up in
[`docs/doc.yuneta.io/philosophy/architecture.md`](docs/doc.yuneta.io/philosophy/architecture.md)
(published at [`/architecture`](https://doc.yuneta.io/architecture)). Four
claims, none of which the code states out loud:

- **A gclass IS a role, and must be named for it.** The `.h` publishes no
  structure (only `GOBJ_DECLARE_GCLASS` + `register_c_xxx`) and `priv` is
  opaque, so what the class does for others is the whole of its identity. Read
  the rule backwards as a design test: a gclass you cannot name by its role has
  none — it is a bag of code, or two roles fused. The gclass names the
  *generic* role, the `gobj_name` the *situational* one.
- **One communication mechanism, two addressing modes.** Pointer inside a yuno,
  service name between yunos. The mechanism does not change when a message
  leaves the process — which is the whole reason only a named service can hold
  either end of an inter-yuno conversation.
- **TreeDB is the STRUCTURE, not the channel.** It holds what exists and how it
  relates; messages stay the only channel. It also publishes
  `EV_TREEDB_NODE_*`, so a change of shared state returns through that one
  channel — which is why polling is refused.
- **Above the linked topics lives a layer of services and roles that is DATA,
  not code.** It changes with CRUDLU while the system runs, and it is what the
  end user sees (who never sees a gclass). Not a third store — the same linked
  nodes read another way. The compiled layer says what the software CAN do; the
  dynamic layer says what THIS installation does, and for whom.

Read it before arguing about where a new gclass, a new service, or a new
configurable structure belongs.

### Core Paradigm: GObject + Finite State Machines

Every component is a **GObject** (`gobj`), instance of a **GClass**. GClasses define:
- **States** and valid **events** per state.
- **Attributes** (typed schema via `sdata_desc_t`, `SDATA()` macros).
- **Commands** exposed to the control plane.
- **Action functions** invoked when an event fires in a state.

GObjects form a hierarchical tree — a **Yuno** (deployable process). Communication
happens exclusively via events carrying JSON payloads (`json_t *kw`).

### GObject design rules

- **The OS boundary is where events are born: EVERY asynchronous notification
  from the system becomes a Yuneta event.** That is the whole point of the
  framework — the FSM is the only place where things happen, so the runtime can
  be audited (the `machine` trace *is* the execution log). In C this is already
  the contract: `yev_loop` turns every io_uring completion (fd readable/
  writable, timer, signal) into an event delivered to a gobj. **The rule is
  general, and the browser is just another operating system**: a DOM click, an
  `onmessage`, a `setTimeout`, a `resize`, a resolved promise are OS
  notifications and must enter the machine as events (`gobj_send_event`), not
  run application logic inside the callback. The callback's only job is to
  translate the OS notification into an event; anything that bypasses that path
  is invisible to the trace and, by construction, undebuggable.
- **Fix the cause at its layer.** A high-level gclass must not compensate for a
  lower layer: reconnection/backoff belongs to the transport (`c_tcp`) and the
  session gclass, never to the application on top. Don't scatter `C_TIMER`s to
  "kick" the layer below, and never mask a crash with a defensive NULL check —
  find why the pointer is NULL. A semantic guard
  (`if(!gobj_is_playing(gobj))`) is fine; a pointer-existence test papering
  over the cause is not.
- **Explicit lifecycle — Yuneta is not a JVM.** Prefer, in order: (1) static
  child gobjs whose create/start/stop/destroy pair with the parent's
  lifecycle; (2) self-destroy at a clear end-of-work point
  (`if(gobj_is_volatil(gobj)) gobj_destroy(gobj)`); (3) last resort, a
  gclass-local deferred destroy via `gobj_post_event(gobj, EV_X, kw)`, which
  delivers the event to the gobj itself on the next cycle of the loop.
  **Never a `C_TIMER0` of 1 ms for this**: a deferral is not a time, and
  writing it as one costs an io_uring timeout, a child gobj, and the name of
  the event — every continuation then arrives as `EV_TIMEOUT` and the
  `machine` trace stops saying what happened. A timer is for a real time: a
  schedule, an inactivity window, a backoff. Contract in `gobj.h`, worked
  example in [`GOBJ.md`](yunos/c/yuno_agent/GOBJ.md) §8.14.
- **No polling.** A timer re-issuing the same query is a discarded pattern in
  Yuneta — remove it when found. Alternatives, in order: on-demand refresh
  from a user action, or have the producer **publish an event** the consumer
  subscribes to. Deliberate, user-approved exceptions are commented as such in
  the code — don't "fix" those.
- **Never synchronously stop the publisher's tree from a subscriber callback.**
  `gobj_publish_event` dispatches synchronously — the callback runs inside the
  publisher's stack, and `gobj_stop_tree` there corrupts mid-iteration state.
  Defer: set a `dying` flag + a short timeout and stop from the timer action.
  Applies to C and JS alike.
- **Main services start their work in `mt_play`, not `mt_create`/`mt_start`.**
  `mt_create` creates children; `mt_play` opens queues and starts what
  connects. This is why `run-yuno play=0` leaves a yuno with
  `running=true, playing=false` until `play-yuno`.
- **Inter-yuno communication happens only between named SERVICES** with public
  events/commands. A routed view, a pure child, or any unnamed gobj can never
  be the `src` or destination of an inter-yuno message — answers route back
  via `gobj_find_service(src_service)`. Symptom of a violation: *"gobj service
  not found"* — fix the sender, don't silence the log.
- **Declarative JSON is sugar over a runtime API.** A JSON config that
  describes objects to build must call a runtime API at startup, and that API
  stays available at runtime. If a feature needs to mutate a config-described
  structure and "there's no API, it's static" — implement the missing API;
  don't hack around the static config.

### Multi-Language Implementations

- C is primary.
- `kernel/js/gobj-js/` — JavaScript framework (Vite).
- `kernel/js/gobj-ui/` — Yuneta UI Library (reusable GUI components).

### Layered Build Dependencies

Build order (each layer depends on all above):

```
kernel/c/gobj-c       ← core GObject framework, logging, buffers, JSON config
kernel/c/libjwt       ← JWT authentication
kernel/c/ytls         ← TLS abstraction (OpenSSL and/or mbedTLS, runtime selectable)
kernel/c/yev_loop     ← async event loop (io_uring-based)
kernel/c/timeranger2  ← append-only time-series persistence
kernel/c/root-linux   ← runtime GClasses: TCP/UDP, timers, channels, protocols, DB
kernel/c/root-esp32   ← ESP32 port
modules/c/*           ← optional: console, mqtt, postgres, test
utils/c/*             ← CLI tools (ycommand, ytests, ylist, …)
yunos/c/*             ← deployable services (mqtt_broker, yuno_agent, …)
tests/c/*             ← test suites (run via ctest)
performance/c/*       ← benchmarks
stress/c/*            ← stress test programs
```

### Key Source Locations

| Path | What's there |
|------|-------------|
| `kernel/c/gobj-c/src/gobj.h` | GClass macros, SData types, FSM API |
| `kernel/c/gobj-c/src/gobj.c` | Core ~12K-line GObject runtime |
| `kernel/c/yev_loop/` | Non-blocking event loop (io_uring) |
| `kernel/c/timeranger2/` | Time-series DB (append-only, key-indexed) |
| `kernel/c/linux-ext-libs/` | External dep libs (OpenSSL, liburing, …) |
| `kernel/c/root-linux/src/` | Runtime GClasses (`c_tcp`, `c_timer`, `c_prot_*`, `c_treedb`, …) |
| `kernel/js/` | JS implementations (gobj-js, gobj-ui) |
| `yunos/c/mqtt_broker/` | MQTT v3.1.1 + v5.0 broker |
| `yunos/c/yuno_agent/` | Yuno lifecycle manager (start/stop/update) |
| `utils/c/ycommand/` | Control-plane CLI |
| `tests/c/` | Test suites (ctest) |
| `performance/c/` | Benchmarks (TCP, TLS, ping-pong) |
| `tools/cmake/` | CMake toolchain files |
| `packages/deb`, `packages/rpm` | Debian/RPM packaging (used by `release-packages.yml`) |
| `scripts/` | Utility scripts (added to `PATH` by `yunetas-env.sh`) |
| `docs/doc.yuneta.io/` | MyST (mystmd) documentation site |
| `outputs/` | Compiled libs, headers, yuno binaries (created by `yunetas init`) |
| `outputs_ext/` | Built external libraries |

**The Python deploy tools live in the CLI, not in `tools/`** (since CLI
0.17.0): `sync_binaries`, `sync_configs` and `set_start_priorities` are
`yunetas.agent_tools.*`. They were one tool on two release channels — a
`pipx` upgrade could hand a new flag to a script from an older SDK. The files
under `tools/agent/` are deprecated forwarding shims; do not edit them.

**`tools/` ships in the install packages; `scripts/` is repo-only.**
Node-usable / operator-facing utilities go under `tools/` (grouped by
subsystem, e.g. `tools/agent/`); repo-dev helpers stay in `scripts/`. Test:
"must this run on a bare installed node?" — yes → `tools/`.

### Module notes

- **HTTP parsing:** llhttp is vendored (`kernel/c/gobj-c/src/llhttp*`;
  http-parser was dropped as unmaintained) and bridged to the gobj event model
  by `kernel/c/root-linux/src/ghttp_parser.c` (used by `c_prot_http_cl` /
  `c_prot_http_sr`). The wrapper has caused repeated bugs — when investigating
  an HTTP parsing issue, suspect `ghttp_parser` first, not llhttp upstream.
- **MQTT:** `C_PROT_MQTT2` (`c_prot_mqtt2.c`) is the complete implementation
  and the only target for protocol-level work; `C_PROT_MQTT` is **deprecated**,
  kept only until the remaining gates migrate. Neither implements
  publish/subscribe ACL (open design item, see `TODO.md`) — don't assume
  topic-level permissions exist.

## ⚠️ GClass templates and skeletons

**Mandatory: every gclass — C or JS, child or service — and every standalone or
citizen yuno must follow the structure of the matching template under
[`utils/c/yuno-skeleton/skeletons/`](utils/c/yuno-skeleton/skeletons).**

The skeletons are the single source of truth for: section banners, banner order,
banner indentation/format, blank-line spacing, function block headers, and the
canonical `mt_create` subscription block. Open the matching template and copy
the structure exactly — count blank lines if in doubt.

| Flavour                   | Skeleton                                                     |
|---------------------------|--------------------------------------------------------------|
| C service-level gclass    | `gclass_service/c_+rootname+.c_tmpl` (+`.h_tmpl`)            |
| C child-level gclass      | `gclass_child/c_+rootname+.c_tmpl` (+`.h_tmpl`)              |
| JS gclass                 | `js_gclass/+rootname+.js_tmpl`                               |
| Standalone yuno (C)       | `yuno_standalone/src/c_+rootname+.c_tmpl` (+`.h_tmpl`)       |
| Citizen yuno (C)          | `yuno_citizen/src/c_+rootname+.c_tmpl` (+`.h_tmpl`)          |

The **JS GUI yuno** scaffold (Vite + declarative shell on gobj-ui v2) lives in
`wattyzer/templates/js_gui/` (a private repo) since 2026-05-21. Use that copy
when starting a new declarative-shell GUI yuno.

**Every banner from the skeleton must be present, even when its section is
empty.** Don't add extra banners outside the skeleton set. Don't reorder
sections in legacy gclasses (e.g. `c_tcp_s.c`, `c_udp_s.c`) — merge new code
into the existing layout to keep `git blame` clean. Greenfield gclasses follow
the skeleton order.

`c_yuno.c` and `c_agent.c` are canonical large-gclass examples; `c_timer.c` is
the minimal example.

### GClass public interface

A gclass `.h` exposes exactly two things: `GOBJ_DECLARE_GCLASS(C_FOO);` and
`PUBLIC int register_c_foo(void);`. Any other interaction goes through the five
public mechanisms: attributes (`SDATA` + `gobj_read/write_*_attr`), commands
(`SDATACM` + `gobj_command`, ycommand-discoverable), events (`gobj_send_event`
/ `gobj_publish_event`), local methods (`gobj_local_method`), and statistics
(`SDF_STATS/RSTATS/PSTATS`, or `mt_stats` for hot paths). Adding raw `PUBLIC`
C functions to a gclass header is a layering violation — treat existing ones
as defects.

### GClass subscription model (CHILD vs SERVICE)

Every gclass picks **exactly one** pattern and writes the block verbatim inside
`mt_create`. Do not invent a third path or weaken either pattern to silence a
runtime error.

```js
/*
 *  CHILD subscription model
 */
let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
if(!subscriber) {
    subscriber = gobj_parent(gobj);
}
gobj_subscribe_event(gobj, null, {}, subscriber);
```

```js
/*
 *  SERVICE subscription model
 */
const subscriber = gobj_read_pointer_attr(gobj, "subscriber");
if(subscriber) {
    gobj_subscribe_event(gobj, null, {}, subscriber);
}
```

- **CHILD** — gobj created with a parent (`gobj_create(name, GCLASS, kw, parent)`);
  the parent is the natural audience. The parent's FSM **must** declare every
  event the child publishes.
- **SERVICE** — gobj created via `gobj_create_default_service` /
  `gobj_create_service`; subscribers opt in explicitly via the `subscriber` attr.

When `gobj_publish_event` from a CHILD raises *"Event NOT DEFINED in state"*,
the fix is one of: declare the event/action in the parent's FSM, or confirm the
publishing gobj is a SERVICE and switch its `mt_create` accordingly. **Never**
strip the parent fallback from a CHILD to silence the error.

### Optional-subscriber events: `EVF_NO_WARN_SUBS`

When a SERVICE publishes an event whose subscribers are optional, tag it with
`EVF_NO_WARN_SUBS` in `event_types`. Otherwise `gobj_publish_event` logs
*"Publish event WITHOUT subscribers"* every time — noisy and not actionable.

```c
event_type_t event_types[] = {
    {EV_TIMEOUT_PERIODIC, EVF_OUTPUT_EVENT|EVF_NO_WARN_SUBS},
    {NULL, 0}
};
```

The flag is the explicit *"missing subscriber is not a bug"* annotation; don't
use it as a generic noise suppressor. Keep the warning when the gclass really
requires someone to react (e.g. a CHILD whose parent must handle the event).

Examples: `c_yui_main.js` (`EV_RESIZE`/`EV_THEME`), `c_yui_window.js`
(`EV_WINDOW_*`), `c_yuno.c` / `c_esp_yuno.c` (`EV_TIMEOUT_PERIODIC`),
`c_agent.c` / `c_agent22.c` (`EV_PLAY_YUNO_ACK`/`EV_PAUSE_YUNO_ACK`).

## JS framework and GUI rules

### gobj-js gotchas

- `createElement2(["tag", attrs, children])`: the 2nd slot MUST be an attrs
  object; text/children go in slot 3 (`["p", {}, "msg"]`,
  `["ul", {}, [li1, li2]]`).
- **A string in slot 3 of `createElement2` that starts with `<` is MARKUP**
  (it runs), unless the app called `set_strings_are_text(true)` (gobj-js
  7.26.3+). Data goes in as a Text node (`text_node()` in gobj-ui); markup
  you mean goes in `html_markup('<i class="yi-…"></i>')`. gobj-ui (7.26.10+),
  `yunos/js/*` and the yunovatios GUIs run in that mode, and their `prebuild`
  runs the check gobj-ui publishes
  (`node_modules/@yuneta/gobj-ui/scripts/check-markup-strings.mjs`, with
  `acorn` declared by the app). So do wattyzer, yunomusica and the
  `js_gui` scaffold (which takes gobj-ui from npm since 2026-10-11; it used
  to vendor lib-yui). **The default does not flip in 7.x, by decision
  (2026-10-11):** estadodelaire and hidraulia are frozen on gobj-ui v1, whose
  markup is plain strings, and take gobj-js through `^7.22.2` -- a flipped
  default would reach them by their range and break them. Every v2 app opts
  in at startup instead. **The static check is not enough to turn the mode
  on:** it cannot see markup a helper RETURNS (`svg(path, size)` in
  yunomusica drew every icon as text under the mode and failed 11 of 30
  e2e tests); load the app with the mode on and read the console for the
  `html_markup()` warnings before deploying it.
- `gobj_create_default_service()` DOES register in `__jn_services__`, so
  `gobj_find_service("app")` finds it — this line used to say the opposite, and
  it was checked and found wrong on 2026-08-28. `gobj_create2()` adds
  `gobj_flag_service` to a default service before building the gobj, and the
  registration is keyed off that flag. It matters beyond a lookup: a default
  service is a legal `src` for an inter-yuno message, so it can subscribe to a
  backend event and get the answer delivered to it by name (`C_YV_APP` counting
  open alarms for the toolbar badge does exactly that).
- `pure_child` gobjs are not auto-started by `c_yuno.mt_play` — call
  `gobj_start(child)` explicitly.
- `c_ievent_cli` contract (C and JS): a **deliberate** `gobj_stop` /
  `gobj_stop_tree` must still deliver the transport close to the FSM so
  `EV_ON_CLOSE` reaches subscribers when a session was open. Guard the handler
  with `gobj_is_destroying(gobj)`; never detach `onclose` to silence a late
  event.
- SPA teardown order: `set_remote_log_functions(null)` FIRST, before dropping
  the websocket or destroying the shell, in every teardown path (logout,
  open-error, restore-failed) — otherwise every teardown log recurses through
  the dead socket into *"too much recursion"*.

### JS GUI DOM: logical class names on important blocks

**Mandatory in every JS GUI (gobj-ui and the `yunos/js/*` yunos alike).** When
a gclass builds DOM, tag its elements so the tree is self-describing in the
browser Inspector:

- **Root of the view:** the `GCLASS_NAME` class **plus** a logical card name,
  e.g. `class="C_AGENT_CONSOLE CONSOLE_CARD view-card"`.
- **Every meaningful child** (status line, response panel, input row, input,
  button, list…) gets a logical class, **prefixed by the view/feature name**:
  `CONSOLE_STATUS`, `CONSOLE_COMMENT`, `CONSOLE_RESPONSE`, `CONSOLE_INPUT_ROW`,
  `CONSOLE_INPUT`, `CONSOLE_EXEC`, …

**Casing: `UPPER_SNAKE`, exactly like the gclass names** — `CONSOLE_COMMENT`,
never `console-comment`. CSS/styling classes stay lowercase (`view-card`,
`is-size-7`), so in a `class` attribute the case alone tells the two
namespaces apart: **uppercase = logical block name, lowercase = styling**.
Keep the existing Bulma/utility classes; **prepend** the logical name(s).

**Logical names are independent of whatever CSS class names each app uses.**
They form their own namespace: they identify blocks, they don't style them,
and they are tied to no CSS framework or app stylesheet (Bulma today, anything
else tomorrow). Each app keeps its own styling classes alongside them —
restyling or swapping the CSS layer never renames a logical class, and adding
a logical class never requires a CSS rule.

**Why:** a bare `<pre class="is-size-7 mb-2">` is unidentifiable in devtools —
you can't tell it's "the comment line". `CONSOLE_COMMENT` makes the DOM
self-describing. These are primarily debug aids, but they **may** double as
real CSS hooks — styling them is fine when useful (e.g. the `.CONSOLE_INPUT_ROW`
rules in `gui_agent/src/app.css`).

Canonical example: `yunos/js/gui_agent/src/c_agent_console.js` (the full
`CONSOLE_*` family).

### JS GUI conventions

- **EVERY action goes through the FSM — a button click IS an action.** This is
  the GUI face of the general rule above (*the OS boundary is where events are
  born*): in a browser the DOM **is** the operating system, so its callbacks —
  click, `onmessage`, `setTimeout`, `resize`, a resolved promise — are OS
  notifications and must be turned into Yuneta events. A DOM handler's only job
  is to **send an event** (`gobj_send_event(gobj,
  "EV_OPEN_CARD", {...}, gobj)`); the work belongs in the FSM action. Same for
  anything the view decides to do on its own (re-arm on reconnect, refresh,
  close). A view whose whole life happens in `ST_IDLE`, with DOM callbacks
  calling functions directly, throws away Yuneta's main debugging asset: the
  `machine` trace shows nothing, so failures can only be chased through
  WebSocket traffic and screenshots. Model the lifecycle as **states** too
  (e.g. "no topic loaded yet" is a state, not an `if(!priv.x) return` that
  silently no-ops a button), so an action arriving in the wrong state fails
  LOUDLY and names its sender. Cards/items do **not** need to be child gobjs —
  keeping them in the same gclass is fine; what matters is that every action
  crosses the automaton. Canonical example: `C_TRANGER_VIEW`
  (`yunos/js/gui_treedb`) — `ST_DISCONNECTED` → `ST_LOADING_TOPICS` →
  `ST_TOPIC_SELECTED`, with every click/`on_close`/dialog-confirm sent as an
  event. Widget plumbing that is not an action stays a plain call (Tabulator's
  `ajaxRequestFunc` must RETURN a Promise — it is a data source, not an event).
- **A `kw` must be plain JSON — never put a gobj, a widget or a DOM node in
  it.** The machine trace dumps the kw (`trace_json(kw)`), and those objects
  are circular: serializing one throws, so the first thing to break is the
  trace the FSM exists to feed. Pass an IDENTITY instead (`{key, mode}`, an
  id) and resolve it to the object inside the action — it also makes the trace
  line readable.
- **EVERY control carries a `title` AND an `aria-label`, and both are
  translatable. No exceptions.** A control is any `input`, `select`,
  `textarea`, `button` or anything that behaves as one. Write all four
  attributes at the point the control is built:

  ```js
  ['button', {class: 'FOO_SAVE button',
              title: t('save'),      'data-i18n-title': 'save',
              'aria-label': t('save'), 'data-i18n-aria-label': 'save'}, …]
  ```

  This is a floor, not a preference, and it is the rule the whole ecosystem was
  swept against: **zero controls without a name**, on the deployed page, in all
  six SPAs. The three things it exists to stop, each of which shipped:

  - **A label is not a name.** Bulma's `field` puts the `<label>` BESIDE the
    control — no `for`, no wrapping — so it names the box for the eye and for
    nothing else; and `<label for=x>` matched against a control carrying only
    `name=x` looks right in the source and associates nothing. A `<label>` that
    WRAPS its control does name it, and is the one shape that needs no
    `aria-label`.
  - **A placeholder is not a name** either: it disappears the moment something
    is typed, and a reader is not obliged to announce it.
  - **A visible label is not enough**, because it disappears: a button whose
    text is in an `is-hidden-mobile` span is a bare icon on a phone. And where
    the visible text is the STATE (`on`/`off`, `validated`/`pending`), the
    `aria-label` is what says what the control DOES — so it must be there, and
    it must be a `t()` with its key. A LITERAL `aria-label` beside a visible
    `i18n` label is worse than none: it OVERRIDES the translated text for a
    reader.

  Two corollaries. What a WIDGET draws for itself carries no attribute anyone
  can set — Tabulator's header filters and its row-selection checkbox, Tom
  Select's box in front of the `<select>` it hides — so it is named after the
  render, and again on every rebuild (`columnsLoaded`, `renderComplete`). And
  an `<option>` is text like any other: it carries `data-i18n`, with `value`
  kept explicit, or a translated option tells the FSM to enter a mode that does
  not exist.

  **The check is not a grep**: dump `title`/`aria-label` from the DEPLOYED DOM,
  resolve each control's name the way a reader does, then switch language and
  diff. A key that arrives as a VARIABLE — from a data table, a helper's
  argument, a local alias of `t()` — is invisible to `validate-locales`, and a
  name written onto a widget's DOM is a race with the next render. Both are
  found only by reading the page.
- **Every text goes through i18n — and must be able to CHANGE language.**
  Passing a string through `t()` once is NOT enough: `refresh_language()` only
  re-translates a node that **carries its key**, so anything composed at render
  time stays in the old language for the life of the view. Text needs
  `i18n`/`data-i18n`; a `title`/`aria-label` needs `data-i18n-title` /
  `data-i18n-aria-label`; a composed string (`` `${key} · ${t(mode)}` ``) must be
  split so the translatable halves carry their own key (`createElement2` **trims**
  text nodes — space a separator with CSS). What a WIDGET draws (a Tabulator
  header, its paginator, a formatter) is reachable by no attribute: the view
  subscribes to the shell's **`EV_LANGUAGE_CHANGED`** (published by
  `yui_shell_language_changed`, which the app calls after switching its i18next)
  and re-renders **in the action** — never from a raw
  `i18next.on("languageChanged")` listener. Tabulator's own chrome goes through
  `yui_tabulator_lang(t)` / `yui_tabulator_relocalize(table, t)` (gobj-ui).
  **A missing key is invisible**: i18next answers an unknown key with the key
  ITSELF, so it renders in lower-case English and never changes language (that is
  how 46 strings of the treedb editor shipped untranslated); a **duplicate** key
  in a locale file is silent too (an object literal keeps the last one). Both are
  caught by `scripts/validate-locales.mjs` — copy it into any new SPA; it also
  scans the gobj-ui modules the app mounts, because **the library translates
  through the APP's i18next**. Details: gobj-ui's README ("Conventions → i18n").
- **Indentation is always FOUR spaces.** Anywhere structure is shown as
  indentation — a rendered tree (site map, JSON viewer), a `<pre>` dump, any
  `JSON.stringify` that reaches the screen — one level is four characters:
  `JSON.stringify(v, null, 4)`, never `2`. Rendered trees indent in **`ch`**
  (`padding-left: 4ch`), not `rem`, so the guides follow the row's own
  monospace font instead of drifting from the text at some zoom level. **Why:**
  the reader is using the indentation to see the shape, and a shape whose width
  changes between two panels of the same app is one more thing to decode.
- **No transitions/animations.** Menus, popovers, tooltips and state changes
  appear instantly — no fade/slide/glide. If a third-party lib injects
  transition CSS, override it (`transition: none !important`). Don't add
  decorative animation unless explicitly requested.
- **Buttons in rows: icon required; label optional, icon-only when it doesn't
  fit.** Every button in a row that gets narrow on mobile carries an icon.
  **Keep the text label** when the labelled row still fits at the narrowest
  supported width (~360px) **in the longest supported locale** — a bare icon is
  always less legible, and two bare icons side by side read as the same
  control. Drop to icon-only (`<span class="is-hidden-mobile">Label</span>`)
  only when it would not fit. **Decide this once, at design time, against the
  longest locale — never measure at runtime:** the width depends on the
  language (`raw json` vs `JSON crudo`), so a measured rule shows text in
  English and icons in Spanish in the same toolbar, and flips shape on
  `EV_LANGUAGE_CHANGED`. The `title` + `aria-label` are mandatory here as
  everywhere (see the rule above) — icon-only is simply the case where they
  are the ONLY name. Verify against the real stylesheet — jsdom does not load
  Bulma, so it cannot catch this.
  Canonical pair: `C_YUI_TREEDB_TOPICS`'s toolbar never holds more than two
  buttons, so its labels stay on mobile (a deliberate, commented exception);
  `C_YUI_TREEDB_GRAPH`'s has many more and keeps `is-hidden-mobile`.
- **Bulma helpers carry `!important`** (`.is-flex`, `.is-hidden`): a bare
  inline `style.display = 'none'` loses to them. Toggle `is-hidden`, or use
  `style.setProperty('display', 'none', 'important')` /
  `removeProperty('display')`. jsdom tests don't load Bulma CSS and can't
  catch this — verify display toggles against the real stylesheet.
- **`yui_icons.css` (gobj-ui) is a small CSS-mask set, not FontAwesome** — an
  undefined `yi-*` class renders as a solid black square. Grep the real set
  before using an icon
  (`grep -oE '^\.yi-[a-z0-9-]+::before' src/yui_icons.css`); a missing glyph
  is added as a deliberate mask rule, never referenced on hope.
- **One icon shape per meaning — never reuse a glyph because it is there.**
  The shape says what the control does:

  | Shape | Meaning | Icons |
  |---|---|---|
  | chevron | opens or closes ONE thing, or scrolls | `yi-chevron-*` (row handle, pill, toolbar scroll, period nav) |
  | shafted arrow | steps through a sequence, or goes back | `yi-arrow-up/down/left/right` (layout stepper, back) |
  | tree box | one LEVEL of the whole tree | `yi-square-minus` / `yi-square-plus` (fold stepper) |
  | double chevron | ALL of it | `yi-angles-down` / `yi-angles-up` (expand / collapse all) |

  **Why:** until gobj-ui `7.23.147` the chevron did four jobs, several in ONE
  row — the treedb graph's toolbar had four `chevron-down`s (two select
  carets, next layout, one more level), and "expand all" everywhere was a row
  handle's chevron rotated 90° by CSS, because it was "the only one
  available". A new meaning gets its own mask (the paths come from
  FontAwesome Free, solid; the set already mixes 6.x and 7.x) and never a
  rotation of an existing one. Before adding a control, look at what else is
  in the row: two controls with the same shape and different actions is the
  bug. The native `<select>` caret is a chevron-down too — do not put a
  chevron-down button beside one.

### Writing tests against the gobj framework

- **`gobj_end()` must run BEFORE any `get_cur_system_memory()` check.**
  `gobj_start_up()` allocates a small baseline (~104 bytes) only freed by
  `gobj_end()`. With `CONFIG_DEBUG_TRACK_MEMORY` a leak check before
  `gobj_end()` always reports that baseline as a leak. Pattern:

  ```c
  do_test();
  gobj_end();                      // free baseline FIRST
  size_t leaked = get_cur_system_memory();
  if(leaked != 0) { /* real leak */ }
  ```

  To distinguish a real leak from the baseline, probe across 0 / 1 / N
  iterations: a constant delta means it belongs to startup, not the loop.

- **`set_expected_results()` + `test_json(NULL)` is strict FIFO.** Every
  `gobj_log_info` / `gobj_info_msg` emitted must appear in the `error_list`
  once per emission, in order. If a phase runs twice, every recurring log line
  must appear twice.

- **`set_expected_results()` / any `json_pack` that `test_json` will DECREF
  must run inside `register_yuno_and_more`**, not in `main()` before
  `yuneta_entry_point` — jansson is routed through `gbmem_*` only after the
  entry point calls `json_set_alloc_funcs`. Earlier allocations are
  libc-backed and die at cleanup (`dl_delete: DIFFERENT dl_list_t`,
  `free(): invalid pointer`). Canonical: `tests/c/c_task_authenticate/`.

- **No `static json_t *` caches in library helpers** — anything alive past
  `gobj_end()` is reported as a leak under `CONFIG_DEBUG_TRACK_MEMORY` and
  fails ctest. Parse/build per call for rare helpers; for hot paths hand the
  cache lifetime to the caller via incref/decref (see `topic_cols_desc` in
  `tr_treedb.c`). Trust ctest over a standalone run — ctest captures the
  `print_track_mem` errors.

## Event Loop & Async I/O

`yev_loop` drives everything via **Linux io_uring** (not epoll). GClasses attach
`yev_event_t` handles for fd I/O (TCP/UDP, serial), timers, signals.

All callbacks return without blocking. **No threading** — scaling is one yuno
per CPU core, with inter-yuno messaging.

## Persistence (timeranger2)

- Append-only log files (`tranger2_open_topic`, `tranger2_append_record`).
- Key-value index for fast retrieval by primary key or time range.
- Used by MQTT broker (sessions/subscriptions/queues) and treedb.

## TreeDB (tr_treedb) — Graph Memory Database on timeranger2

Graph memory database using timeranger2 for persistence. Nodes belong to
**topics** and link via **hook/fkey** relationships.

**Every treedb is a graph**: topics = nodes, hook→fkey columns = links
(a self-referent hook = a tree). Docs, schema references and SPA views lead
with the graph; column tables are the per-node detail. Derive graph diagrams
from the schema source of truth, never hand-draw them.

### Key Concepts

- **Topics**: collections of nodes (e.g. `departments`, `users`) with a schema.
- **Nodes**: JSON objects identified by `id`. Each has `__md_treedb__` metadata
  containing `g_rowid`, `i_rowid`, `t`, `tm`, `tag`, `pure_node`, `topic_name`,
  `treedb_name`.
- **Hooks**: fields on **parent** nodes referencing children. In-memory only.
  Defined with `'flag': ['hook']` and a `'hook'` mapping
  (`{'departments': 'department_id'}` = "child departments link back via their
  `department_id` fkey").
- **Fkeys**: fields on **child** nodes storing persistent references to parents.
  Defined with `'flag': ['fkey']`. Values like `"departments^direction^departments"`
  (topic^parent_id^hook_name).
- **Never Hook+Fkey on one column**: the treedb writes one half of a link.
  `parse_schema()` and `treedb_create_topic()` refuse a column flagged both
  (*"A column cannot be both 'hook' and 'fkey'"*), as gobj-ui's schema editor
  does (`EXCLUSIVE`). It used to be accepted, and its fkey side was never
  written to disk. A node that is both a child and a parent carries two
  columns: the hook, and the fkey.
- **A `treedb_schema_<db>.c` holds what the schema editor exports, and
  nothing else**: the GRAPH of the schema as a comment, then the literal.
  `<db>` is the treedb id without its `treedb_` prefix (`treedb_wattyzer` →
  `treedb_schema_wattyzer.c`, array `static char treedb_schema_wattyzer[]`).
  No function, no hand-written note, no second literal (a msg2db schema gets
  its own `msg2db_schema_<name>.c`). The export is `schema_to_c()` in gobj-ui,
  and the graph is `schema_to_diagram()`, DERIVED from the literal, never
  drawn by hand — so an edit made in the GUI goes back into the source by
  replacing the file whole, picture included. The yuno `#include`s the file;
  design notes go in the file that includes it. For a file edited by hand,
  regenerate its comment with `node scripts/schema_diagram.mjs <file>` (the
  literal is left as it is); `--check` exits 1 when a comment is stale.
  Exception: the meta-schema `kernel/c/timeranger2/src/treedb_system_schema.c`
  (an `extern` array with its own header, not something the editor exports).

### Persistence Rules (CRITICAL)

- Every `tranger2_append_record()` writes a new record, incrementing `g_rowid`
  (cumulative) and `i_rowid` (md2 row index) for that key.
- **Only `g_rowid` and `i_rowid` are set by timeranger2.c** — never modify them
  directly in test code.
- `treedb_create_node()`: appends → g_rowid=1.
- `treedb_link_nodes(hook, parent, child)`: updates the **child's fkey** and
  saves the **child** → child's g_rowid increments. Parent NOT saved.
- `treedb_unlink_nodes(hook, parent, child)`: clears the **child's fkey** and
  saves the **child** → child's g_rowid increments. Parent NOT saved.
- **Key rule:** link/unlink saves ONLY the child (the one with the fkey),
  NEVER the parent (the one with the hook).
- **A link saves the child only if the CHILD changed** (its fkey took a new
  ref, or a single-valued fkey replaced its parent). Linking a pair that is
  already linked writes nothing and publishes nothing; filling only the
  PARENT's hook (a new instance that inherited the fkey) publishes the link but
  saves nothing, because the hook lives in memory. So "link → child g_rowid+1"
  holds only when the fkey moved.
- **Changing a topic's cols requires bumping its `topic_version`** (and
  `schema_version` for structural changes) — otherwise the persisted
  `topic_cols.json` masks the new in-memory schema and validation fires stale
  errors. When reproducing locally, wipe the treedb `store/` first or the fix
  won't take. Details:
  [`YUNO_TREEDB.md`](yunos/c/yuno_agent/YUNO_TREEDB.md).

### Hook Mappings Example (schema_sample)

```
departments.departments: hook → {'departments': 'department_id'}
    (child department's department_id fkey is updated)

departments.users: hook → {'users': 'departments'}
    (child user's departments fkey is updated)

departments.manager: fkey
    (updated when this department is linked as child via managers hook)

departments.managers: hook → {'users': 'manager', 'departments': 'manager'}
    (linking a user child: user's manager fkey is updated → user saved)
    (linking a department child: department's manager fkey is updated → department saved)
```

### Test Fotos (Expected State Snapshots)

Test "foto" files (`foto_final1.c`, `foto_final2.c`, `foto_final3.c`) contain
the expected full treedb state as JSON at specific points. When updating fotos:

- Nodes appear **multiple times** in the JSON (in `id` index and nested inside
  parent hooks). All occurrences share the same `g_rowid`/`i_rowid`.
- Different checkpoints may need different foto files if g_rowid values differ.

## Control Plane (ycommand)

Running yunos expose commands and stats over a local socket:

```bash
ycommand -c 'help'                                       # list commands of the default yuno
ycommand -c 'stats'                                      # get stats
ycommand -c 'list-yunos'                                 # list managed yunos with pid/status
ycommand -c 'list-binaries'                              # list stored binaries
ycommand -c 'kill-yuno id=<id>'                          # stop a yuno
ycommand -c 'update-binary id=X content64=$$(X)'         # upload new binary (same version)
ycommand -c 'run-yuno'                                   # start all enabled yunos
```

### Over ssh, set the PATH first — an ssh command is not a login shell

`ssh node '<command>'` cannot find `ycommand`, or any other tool of
`/yuneta/bin`. What puts them on the PATH is `/etc/profile.d/yuneta.sh`, and
**profile.d is read by LOGIN shells only**; a remote command is neither login
nor interactive, so it starts with the bare
`/usr/local/bin:/usr/bin:/bin:/usr/games`. Whoever comes in that way sets
their own PATH, and it is the first thing they do:

```bash
ssh node '. /etc/profile.d/yuneta.sh; ycommand -c "list-yunos"'
```

`bash -lc` works too, but nesting quotes inside an ssh command turns
unreadable fast; sourcing says out loud where the PATH comes from.

Two things worth knowing about this one:

- **It hides on a Red Hat node.** Bash *does* read `~/.bashrc` for a remote
  command, and Red Hat's has no interactivity guard, so the same line works
  there and fails on Debian — where `~/.bashrc` returns at once for a
  non-interactive shell and never reaches its yuneta lines. Same package,
  opposite behaviour; do not conclude a node is broken from it.
- **The limits are NOT affected.** `memlock`, `nofile` and `core` arrive
  through PAM (`/etc/security/limits.d/*-yuneta.conf`), which does apply to an
  ssh command, so a tool reached this way runs with the same limits as one
  typed by hand. Only the PATH is missing.

**The package does not fix this, deliberately.** Seeding symlinks into
`/usr/local/bin` from the postinst was tried and reverted: `/yuneta` is this
software's territory and the system directories are not. A package that plants
names outside it buys convenience with somebody else's namespace, and has to
keep buying it on every install, upgrade and removal.

### Verifying flows against a running system

Verify message flows against the **backend**, not a SPA (the SPA only
visualizes). Loop: mutate treedb from the CLI
(`command-yuno … service=<treedb_svc> command=update-node …` — treedb replaces
object fields wholesale, resend the full sub-object), inject stimulus over a
persistent connection, and assert on the timeranger2 topic with
`tr2list <store>/<treedb>/<topic> -l3 [--follow]`. Inspect live nodes via
commands (`get-node`, …), never by editing the append-only store on disk — the
running yuno caches treedb in memory and out-of-band writes corrupt the log.

## Debugging a running yuno

### 1. Activate traces

Each GClass defines its own trace levels; global traces apply across all GClasses.

```bash
# Discover available trace levels for a GClass
ycommand -c 'command-yuno id=<id> service=__yuno__ command=get-gclass-trace gclass=<GClass>'

# Enable a GClass trace level
ycommand -c 'command-yuno id=<id> service=__yuno__ command=set-gclass-trace gclass=<GClass> set=1 level=<level>'

# Enable a global trace level
ycommand -c 'command-yuno id=<id> service=__yuno__ command=set-global-trace level=<level> set=1'
```

Global levels include `machine` (FSM events), `create_delete`, `ev_kw`,
`subscriptions`, `commands` (the table is `s_global_trace_level` in `gobj.c`).
`traffic` and `connections` are NOT global: they are GClass levels, and
class-specific (e.g. `C_TCP` has `connections`, `traffic`, `tls`), so a byte
dump is armed with `set-gclass-trace gclass=C_TCP level=traffic` (or a deep
trace). A frame whose gbuffer is marked `gbuffer_set_secret()` is dumped as
`<N bytes hidden>`; so is an `EV_TX_DATA` sent to `C_TCP` with
`"__secret__": true` (only `C_TCP` reads that key).

**When the yuno never reaches the agent**, every command above is useless — they
travel over the control channel that is precisely what is missing, and
`list-yunos` will report `running=false` even while the process is alive. Arm
the traces on the command line instead (7.8.2+, every yuno accepts it):

```bash
<yuno> --config-file='[...]' --global-trace=machine,create_delete
<yuno> --global-trace=list        # available levels
```

Levels apply before the first service starts, so they cover start up itself.
Rebuild the yuno's command line from `running-bin id=<id>` / `running-keys
id=<id>`, or use the script the agent leaves at
`/yuneta/realms/<realm>/<yuno>/bin/<role>^<id>.sh`.
`--verbose-log` is **not** a trace switch: it only picks which fields the stdout
log handler prints. `kill -10 <pid>` cycles the global mask on an already-running
process, but cannot catch start up.

Every yuno `main.c` carries a **commented-out block of trace toggles**
(`gobj_set_gclass_trace(...)`, `gobj_set_global_trace(...)` for the common
levels) after the `arguments.verbose` setup, so tracing is one uncomment away.
Keep/add it when touching a `main.c`; it is not dead code. Canonical:
`utils/c/ycommand/src/main.c`.

### 2. Monitor logs

```bash
ls -lt /yuneta/realms/<realm>/<yuno>/logs/                                # find active log
tail -f /yuneta/realms/<realm>/<yuno>/logs/<N>.log | grep -a "keyword"    # follow + filter
```

### Inspecting configuration

**A yuno's effective config is a merge**: `fixed_config` + `variable_config`
compiled into its `main.c`, deep-merged with the external JSON files (the
overrides layer). Services, `public_services` and gclass trees live only in
`main.c` and never appear in the external JSON. Verify any claim about a
running yuno with
`ycommand -c 'command-yuno id=<id> service=__yuno__ command=view-config'`,
never from the external JSON alone.

Two related gotchas:

- The agent's own configs (`/yuneta/agent/yuneta_agent{,22}.json`) are created
  only by the deb/rpm packagers from `packages/templates/*.json.sample`
  (conffiles — never overwritten on upgrade), and are merged on top of the
  agent's `main.c` defaults the same way.
- A `crypto` JSON attr supplied in a yuno/gate config **replaces** the gclass
  default wholesale (attr-level, no merge). Since TLS clients verify by
  default, any crypto override must repeat the verifying keys
  (`ssl_use_system_ca`, `ssl_verify_mode`, …) or the client silently loses
  them.

### Memory bugs

Debug memory bugs (bad decref, leaks, refcounts) **locally first** when
reproducible — faster cycle, `CONFIG_DEBUG_TRACK_MEMORY` is on, low blast
radius. The stack-trace block printed after a `gobj_log_error` carries line
numbers from the binary *as built* — map frames by **function symbol**, not
line number. Promote to remote nodes only after the local cycle is clean.

After deploying a modified yuno, **cycle it orderly at least once**
(`kill-yuno` + `run-yuno`): the gbmem audit only fires on clean shutdown
(`deactivate-snap` SIGKILLs and skips it). A leak logs *"system memory not
free"* at the end of the yuno's log; a clean shutdown emits nothing.

### 3. Deploy an updated binary

**Always go through the agent — never kill processes manually or overwrite a
running binary directly.**

#### Same-version hot-patch (debug rebuild, `APP_VERSION` unchanged)

```bash
# 1. Build
cd /yuneta/development/yunetas/yunos/c/<yuno>/build && make clean && make install

# 2. Kill the running yuno(s)
ycommand -c 'kill-yuno yuno_role=<role>'

# 3. Overwrite the same-version slot
ycommand -c 'update-binary id=<role> content64=$$(<role>)'

# 4. Verify size/date
ycommand -c 'list-binaries'

# 5. Start the yuno(s), then play them (two steps = one response each)
ycommand -c 'run-yuno play=0'
ycommand -c 'play-yuno'
```

`run-yuno play=0` launches the process(es) without the implicit auto-play, so
it returns a single aggregated response; `play-yuno` then starts the services
(also a single response). In a script this keeps each command in sync. Plain
`run-yuno` (auto-play) still works for interactive use but emits one extra
async answer per `must_play` yuno.

#### Version bump (`APP_VERSION` in `main.c` changed)

`kill-yuno` + `run-yuno` will re-launch the OLD release: the agent's
in-memory primary index for the `yunos` topic does not move when a
new `pkey2` row is appended. The canonical sequence is:

```bash
# 1. Build (APP_VERSION bumped in main.c)
cd /yuneta/development/yunetas/yunos/c/<yuno>/build && make clean && make install

# 2. Install into a NEW slot
ycommand -c 'install-binary id=<role> content64=$$(<role>)'

# 3. Register a yuno-instance row at the new role_version
ycommand -c 'find-new-yunos create=1'

# 4. Trigger restart_nodes() so the new pkey2 becomes primary
ycommand -c 'deactivate-snap'
```

`deactivate-snap` (no args, no active snap) is the supported way to
force the agent to SIGKILL every running yuno, promote the highest
`yuno_release` per id, reload the treedb, and bring everything back
on the new release. (The treedb primary is the highest-**ROWID**
record, not the highest version; `restart_nodes()` re-appends the
newest release first — `promote_highest_release_yunos()` — so the
reload promotes it reliably.) It is a node-wide bounce — use
`shoot-snap name=<tag>` first if you want a rollback point. Full
flow incl. rollback in
[`yunos/c/yuno_agent/YUNO_LIFECYCLE.md`](yunos/c/yuno_agent/YUNO_LIFECYCLE.md)
§6.5 / §6.6.

**CLI shortcut.** Steps 3–4 (plus the optional rollback snapshot) are
bundled by `yunetas upgrade-yunos`: it previews `find-new-yunos` and stops if
there is nothing new, asks, THEN shoots the rollback snap (idempotent by name,
`--no-snap` to skip — never shot for nothing, since 0.19.2), then `create=1`
and `deactivate-snap`. Steps 1–2 (build + `install-binary`,
or a `yunetas sync-binaries` push) still come first. Use it after the new
binaries are installed; the raw `ycommand` sequence above is the manual
equivalent.

#### Deploy conventions

- **Describe steps by what the agent does**, with the command in parens:
  `kill-yuno` is an **orderly shutdown** (drain + deregister), not a SIGKILL —
  "build → orderly shutdown (kill-yuno) → upload binary (update-binary) →
  verify (list-binaries) → start (run-yuno)".
- **Never send a deploy step to `/dev/null`.** A step shows what it answers,
  and somebody reads it. A `db_history` hot-patch once looked done and was
  not: the shell's *command not found* went down the drain together with the
  output that would have shown it, and the yuno kept running the old binary
  while every line of the recipe had "succeeded".
- **Scope redeploys to actual consumers.** A change in a shared kernel lib
  does not mean bouncing every yuno that links it — identify which running
  yunos actually use the changed feature and propose that narrow set; a
  node-wide bounce of live services has real cost.
- **On a node that builds from source, the version that counts is the binary's,
  not the package's.** `dpkg -l` / `rpm -q` reporting an older release next to
  `<binary> --version` reporting the current one is the **correct** state
  there, not a pending upgrade — do not "align" it. The package ships whole
  trees built on the build machine (`/yuneta/bin/nginx`, `/yuneta/bin/openresty`,
  `outputs/`, `outputs_ext/`, `tools/`), so installing it on such a node
  replaces a web server that runs with one built against another glibc, and
  replaces the node's own archives with foreign ones — the provenance trap
  under *Fully Static Binaries* above. What it delivers is a version string;
  what it costs is a full rebuild, external libraries FIRST. `preinst` / `%pre`
  refuse a source node for exactly this reason, and
  `/etc/yuneta/allow-package-over-source` lifts the refusal, never the damage.
- **Security defaults cut over hard fail-closed** — no warn-then-enforce or
  migration windows; outdated peers are allowed to break loudly. On a noisy
  deploy, **snap-rollback first** (`shoot-snap`/`activate-snap`), analyze
  offline, redeploy.
- **Binary and config deploys travel together:** run `sync-configs` right
  after `sync-binaries` — new runtime + stale config has caused a real
  incident.
- **Batch configs** (`yunos/batches/<host>/<yuno>.json`): every edit bumps
  `__version__` and rewrites `__description__` as **that version's changelog
  entry** (what it changes + rollback caveat) — operators read it in
  `list-configs`; rollback is config-row reassignment, not file reverts.
  ⚠️ **A bumped version does not reach the yuno on its own, and the reason is
  not the config's primary index — it is the YUNO's record.** `get_yuno_config()`
  in `c_agent.c` picks the config by `(id, name_version)`, where `name_version`
  is a field **of the yuno row**. So a new config row can exist, and
  `list-configs` can even show it as primary, while the yuno goes on
  materialising the old one — and what the yuno actually runs is the file the
  agent writes at launch, `bin/3-<role>^<name>.json`. **That file is the only
  authority; `list-configs` is not.**

  The order therefore matters and is not the obvious one:

  1. `create-config` (the version travels INSIDE the content, as `__version__`;
     the command takes only `id` and `content64` — there is no `version`
     parameter),
  2. **then** `find-new-yunos create=1` — this is what creates the yuno row
     carrying the new `name_version`,
  3. **then** `deactivate-snap` to promote it.

  Run `find-new-yunos` before `create-config` and nothing happens: the config
  sits in the store and the yuno keeps its old `name_version` for ever.

  For a single-yuno config fix, prefer **overwriting the row in use** with
  `update-config` — which matches by `(id, __version__)`, so the file must
  carry the version already installed — and restart just that yuno. Do not
  bounce a whole node to change one yuno's config.

  ⚠️ **Deleting a config row can leave the index inconsistent**, and then
  `update-config` answers *"Configuration not found"* for a version that
  `list-configs-instances` plainly shows: `list-configs` keeps naming the
  deleted version as primary. A `deactivate-snap` reloads the treedb and
  settles it. (`delete-config` refuses a version still in use unless
  `force=1`.)
- **The agent itself is a standalone daemon, not a managed yuno** —
  `kill-yuno` / `update-binary` / `run-yuno` do nothing to it. Since the
  native units (`yuneta_agent.service`, `yuneta_agent22.service`, after
  7.25.21) deploy it with the new binary moved into place and
  `sudo systemctl restart yuneta_agent` (or `yuneta_agent22`): a hand-run
  `--start` puts the agent OUTSIDE its unit, where systemd does not see it
  and the next boot does not start it. A node still on the SysV script (no
  `/usr/lib/systemd/system/yuneta_agent.service`) keeps
  `yuneta_agent --config-file=<its json> --stop` then `--start`. SIGTERM is
  ignored by design. Every node
  runs `yuneta_agent` + `yuneta_agent22` (minimal escape hatch) as deliberate
  redundancy — each can upgrade the other; **never stop or upgrade both at
  once**.

### 4. Deactivate traces when done

Same commands with `set=0`. Response should be `[]` or only the permanently
configured ones.

```bash
ycommand -c 'command-yuno id=<id> service=__yuno__ command=set-gclass-trace gclass=<GClass> set=0 level=<level>'
ycommand -c 'command-yuno id=<id> service=__yuno__ command=set-global-trace level=<level> set=0'
```

## Release checklist

- **Tags have no `v` prefix.** Before creating any tag, run
  `git tag -l | grep <version>` (catches both prefixed and unprefixed forms);
  if anything matches, stop and ask — duplicate `7.x.y`/`v7.x.y` tags pointing
  at different commits is a serious error.
- **A change that touches only the agent or the controlcenter gets a LITE
  release while a cycle is open** (rule of 2026-09-29). Bump `YUNETA_VERSION`
  (`RELEASE` back to 1), write its CHANGELOG section and tag it as usual, but:
  run only the tests the change affects (not the two-machine suite below), and
  deploy ONLY the affected binary -- the agent with `systemctl restart` of its
  unit (`--stop`/`--start` on a node without the units), one
  agent at a time and a `*.bak-pre-<version>` of the running one first; the
  controlcenter as a new release of 1996/1997 on a.com. No rebuild of every
  yuno, no `sync-binaries` round over the nodes. **Why:** 7.25.13 changed only
  `yuno_agent` and `controlcenter`, and the full procedure (suite on two
  machines, packages, every yuno of every node rebuilt and upgraded) cost
  hours for two standalone binaries. The full procedure comes back when the
  cycle is CLOSED, or when it is strictly necessary -- a kernel change that
  every yuno links is the typical case.
- **The release suite runs on TWO machines before the tag** (rule of
  2026-09-25, kept on 2026-10-01). The wattyzer half runs **only when a new
  SDK version is about to be published** (2026-10-03): not after a round of
  fixes or a review, which run the local suite only. `yunetas test` on the dev machine under
  `ulimit -Sn 1024`, and the full suite on **wattyzer** (build from source in a
  worktree of its own with `--sdk-only`; `. /etc/profile.d/yuneta.sh` first
  over ssh, or `yunetas` is not found and ctest runs stale binaries; never
  `configure-libs.sh` on its live tree, it reinstalls the openresty that serves
  its sites; put back the agent binaries the build installs in `/yuneta/agent`).
  **Why:** 7.25.5 passed 207/207 on one machine and failed on every node. That
  machine differs from the nodes on four axes a test can silently depend on:
  `CONFIG_DEBUG_TRACK_MEMORY` (on locally, off on the nodes), the kernel (7.0
  has fine-grained ctime; Debian's 6.x moves it in 4 ms ticks), the soft limit
  of open files (the agent CLI raises its own to 1048576; a desktop terminal has
  1024), and the DNS (a local stub that answers at once; wattyzer's takes 3 s).
  The rule was dropped for a few hours on 2026-10-01 and brought back the same
  day: that day's wattyzer run failed a timeranger2 test that passes locally,
  because on Debian's 6.12 + ext4 a directory removed and made again keeps the
  same inode AND birth time (3 in 50; never on the dev kernel) -- a real
  defect, visible only on the nodes' kernel.
- **Bumping `YUNETA_VERSION` means resetting `RELEASE` to `1`.** The two files
  are independent and nothing links them: `RELEASE` is the packaging revision
  (`yuneta-agent-<VERSION>-<RELEASE>`), it is bumped alone for a repackage, and
  it does **not** reset itself. Every release before 7.12.0 shipped its first
  package as `-1` because somebody remembered; 7.12.0 shipped as **`-3`**
  because this line was not here. Nothing breaks — the package manager compares
  the version first — but the number claims two earlier revisions that never
  existed, and the next repackage of that version has to keep climbing from the
  wrong floor.
- **Pre-tag audit** (also on any "save" / "save and quit" request), checked
  against `git log <last-tag>..HEAD`:
  1. `CHANGELOG.md ## Unreleased` lists every non-docs/test change.
  2. READMEs of touched modules reflect behavior changes (simple isolated bug
     fixes are exempt; BREAKING changes never are).
  3. `docs/doc.yuneta.io/` mirrors them.
  4. `TODO.md` is pruned of items the commits resolved.
  5. READMEs/docs scanned for stale content (old versions, removed features,
     renamed APIs).
  6. The three checks of the tests pass: `scripts/check_test_ports.py`,
     `scripts/check_test_databases.py` (needs a built `build/`) and
     `scripts/check_test_links.py` (an archive linked by a bare name is never
     relinked when it changes).
  Surface gaps as a punch list before committing/tagging.
- **Performance variations are studied, not assumed** (rule of 2026-09-23).
  Every round of changes that touches a hot path (timeranger2 append/read/open,
  tr_treedb writes, c_treedb, the event loop, protocols) compares speed
  against the last tag, and so does a release that carries such a round.
  `yunetas test` keeps each ctest run in `build/<timestamp>.j<N>.txt` (CLI
  0.21.1+; older runs have no `.j<N>`). Compare runs of ONE job count: under
  `-j` only the `RUN_SERIAL` tests (the benchmarks, the
  `test_topic_pkey_integer` chain) run alone, so the trend of the others is
  read from serial runs (`yunetas test --serial`): grep the tests that time
  something (`grep "timeranger2/test_topic_pkey_integer \.\.\." build/*.j1.txt`)
  and list every test whose time moved more than ~10%. A single run varies
  ±3-4%, so a suspected loss gets a controlled A/B: build the last tag in a
  separate `git worktree` with its own outputs, run both binaries ALTERNATED
  (8+ rounds), report mean and spread. A real loss is either the price of a named
  correctness fix, said in the CHANGELOG with its figure, or it is fixed before
  the tag. Why: the correctness fixes of 7.25.5 were made in many passes and
  nobody measured speed; `test_topic_pkey_integer` had drifted from 1.94 s to
  2.03-2.25 s on the fastest node before anybody looked.
- **A release with a large change in the `.c` files ships a performance
  report: an `.html` with charts that SELLS the release** (rule of 2026-09-23,
  narrowed 2026-09-25: a release of small fixes, tests and packaging -- 7.25.6
  -- ships none, and adds no row to the performance page). When such a version is generated,
  collect the performance figures (the A/B against the last tag above, the
  timing tests of `build/*.txt`, the benchmarks under `performance/c/` —
  appends/s, TLS round-trips, message rates, binary sizes) and build a
  good-looking, self-contained `.html` with charts: this release against the
  previous ones, what each figure means for a user, the machine it was
  measured on. It is written to show the qualities of Yuneta to someone
  deciding whether to adopt it — honest numbers, including any loss with its
  reason — not as an internal log. Keep the raw figures of each release
  (so the next report can chart the trend), and publish the page with the
  release. **It must be easy to reach on GitHub**: commit the page as
  `performance/reports/<version>.html` beside its raw figures
  (`performance/reports/<version>.json`) and a `performance/reports/README.md`
  that lists every report, newest first; attach the `.html` to the GitHub
  release as an asset; and link it from the release body and from the
  repository `README.md` ("Performance"). GitHub shows an `.html` in the tree
  as source, so every link names both the file and a rendered view of it. **And it is
  part of the documented history**: doc.yuneta.io carries a performance page
  (`docs/doc.yuneta.io/performance.md`, in the toc next to the CHANGELOG) that
  grows with every release — the figures of each version as a row of its
  trend charts, the report of each release linked from it — so the site tells
  how Yuneta's speed evolved, release after release, not only what the last
  one measured.
- **A submodule bump of `kernel/js/gobj-js` or `kernel/js/gobj-ui` means
  running `python3 scripts/verify_js_api_coverage.py --repin`, then `--write`.**
  Those two packages carry their own tags, so `check_doc_line_refs.py --repin`
  does not touch their links: it only matches
  `github.com/artgins/yunetas/blob/`. `--repin` retags every hand-written page
  and recomputes its `#L` anchors from each entry's `(js_<name>)=` label;
  `--write` regenerates `api/appendix_js_api_index.md`. Run the script with no
  flag as a guard before tagging — it exits non-zero on a new undocumented
  export, a stale anchor, or an index that drifted.
- **The C API reference is complete before a tag:**
  `python3 scripts/verify_api_coverage.py` shows no `[!!]` (every public
  function of the checked headers has its page entry, with an example), and
  `python3 scripts/api_index.py --check` passes -- the appendix
  `api/appendix_api_index.md` is WRITTEN by `scripts/api_index.py` from the
  headers, never edited by hand (by hand it had drifted 26 functions and two
  whole headers). A gap found is fixed in the same pass, not listed as
  pending: the docs are always current and published.
- **Every release includes the live docs:** repin the `blob/<old>/` /
  `tree/<old>/` deep links across `docs/doc.yuneta.io/**` and
  `yunos/c/yuno_agent/*.md` with
  **`python3 scripts/check_doc_line_refs.py --repin=<NEW_TAG>`** — never a hand
  `sed` over the URL segment. The tag swap is the easy half: the script also
  **recomputes every `#L<line>` symbol anchor to the symbol's current def
  line**, and those drift silently whenever code is inserted above a documented
  function (at 7.8.0 a `sed`-only repin left **210 anchors** across 20 files
  pointing at the wrong line — the tag looked right, so nothing flagged it).
  Run it with no flag first as a guard (exits non-zero if a ref points past
  EOF), and leave "since X" prose alone. Then clear the myst cache (a repin is
  a bulk edit); run `docs/doc.yuneta.io/deploy.sh` and curl-verify the live
  site. The release is not done until the live site reflects it.
- **GitHub release body from the CHANGELOG:** strip the 4-space bullet indent
  from the `## vX.Y.Z` section first (otherwise GitHub renders it as one code
  block). When backfilling an older release, restore
  `gh release edit <newest-tag> --latest` afterwards.

## Environment Variables

| Variable | Purpose |
|----------|---------|
| `YUNETAS_BASE` | Root of this repo (auto-set by `yunetas-env.sh`). `/yuneta/development/yunetas` is the same base on every node: `.deb`/`.rpm` runtime-only nodes get a sparse SDK there (`outputs/`, `outputs_ext/`, `tools/`, `.config` — no sources) |
| `YUNETAS_OUTPUTS` | `$YUNETAS_BASE/outputs` — build artefacts (include/lib/bin/yunos) |
| `YUNETAS_OUTPUTS_EXT` | `$YUNETAS_BASE/outputs_ext` — built external libraries |
| `YUNETAS_YUNOS` | `$YUNETAS_OUTPUTS/yunos/` — deployed yuno binaries |

## Useful Files

| File | Purpose |
|------|---------|
| `YUNETA_VERSION` | Current version (7.26.9) — used to generate `yuneta_version.h` |
| `Kconfig` | Root Kconfig definition |
| `TODO.md` | API renames/removals/additions between versions |
| `CHANGELOG.md` | Release history |
| `ctest-loop.sh` | Runs `ctest` in a loop until first failure |
