# NODE_SEALING.md

**The black-box node: no inbound SSH, access only inward-out through the
controlcenter.**

> **Status: DESIGN PROPOSAL — not yet implemented.**
> This document is the validated mental model for a node-sealing lifecycle.
> The commands, the agent22↔agent heartbeat and the node-state topic described
> here **do not exist in the code yet**. Read the
> [What exists today](#1-what-exists-today-vs-what-this-proposes) table before
> assuming any of it ships. When a piece lands, move it from the "proposed"
> column to the "exists" column and link the commit.

---

## 0. The goal

A provisioned node is a **black box**: no operator can reach it by SSH. The only
way in is *inward-out* — the agent (and its redundant twin, `yuneta_agent22`)
hold an outbound connection to a controlcenter, and the controlcenter opens an
interactive **PTY** back down that connection. The forward door (SSH) is
closed. The reverse door, a PTY over the outbound link of the agent, is the
only door.

The break-glass of last resort is **the hosting provider's out-of-band console**
(OVH rescue mode / KVM / IPMI / serial). There is deliberately **no automatic
un-seal**: a sealed box stays sealed even if both reverse channels die, and is
recovered only from the provider console. This is the security/robustness trade
chosen for OVH-hosted nodes — see [§6](#6-break-glass-the-provider-console-is-the-contract).

---

## 1. What exists today vs what this proposes

| Piece                                   | Status | Where |
|-----------------------------------------|--------|-------|
| `C_PTY` pseudoterminal gclass           | **Exists** | [`kernel/c/root-linux/src/c_pty.c`](../../../kernel/c/root-linux/src/c_pty.c) |
| Agent/agent22 create a PTY for the CC   | **Exists** | [`yuno_agent22/src/c_agent22.c`](../yuno_agent22/src/c_agent22.c) (`gobj_create_service(name, C_PTY, …)`) |
| Outbound controlcenter client (`tcps://`) | **Exists** | both `main.c` (`controlcenter` service, `C_IEVENT_CLI` → `C_PROT_TCP4H`) |
| Dual-homing (agent `:1994` / agent22 `:1995`, `.com`/`.ovh`) | **Exists** | both `main.c` `__output_url__` |
| `node_owner != "none"` gates the CC link | **Exists** | [`c_agent.c`](src/c_agent.c) `mt_start`, [`c_agent22.c`](../yuno_agent22/src/c_agent22.c) `mt_play` |
| Long-lived controlcenter JWT + cert auto-sync | **Exists** | agent config `controlcenter.jwt`, `cert_sync_*` (see [`YUNO_AUTH.md`](YUNO_AUTH.md)) |
| **agent22 survives an empty `node_owner`** | **Proposed** (Hardening #0) | today it `exit(0)`s — [§5.1](#51-hardening-0-agent22-must-never-suicide) |
| **agent22 → agent CC-connection heartbeat** | **Proposed** | [§5.2](#52-the-agent22agent-heartbeat) |
| **`seal-node` / `unseal-node` / `node-seal-status`** | **Proposed** | [§3](#3-command-surface), [§4](#4-the-seal-is-self-proving-and-self-validating) |
| **`/yuneta/bin/yuneta-unseal-local`** (console break-glass) | **Proposed** | [§6](#6-break-glass-the-provider-console-is-the-contract) |
| **Node-state topic + fleet view** | **Proposed** (phase 2) | [§7](#7-node-state-phase-2) |
| **SSH-dependency ledger** — what still needs SSH, and at which tier | **Living inventory** | [§5.4](#54-the-ssh-dependency-ledger) |

---

## 2. The governing property: the OS enforces the seal, not the agent

For a real black box, **the seal must hold even when the agent is dead.** So
every seal step is a *persistent OS-level* change, not something the agent
re-applies on each boot:

| Step | Mechanism | Why it persists |
|------|-----------|-----------------|
| 1 · stop SSH | `systemctl disable --now ssh` | systemd will not start it on boot |
| 2 · firewall :22 | nftables rule dropping inbound `:22`, saved | survives reboot |
| 3 · keys | move every `~/.ssh/authorized_keys` → `…/authorized_keys.sealed`, `passwd -l` the bootstrap user | on-disk, survives reboot |
| 4 · agent port | nftables rule dropping inbound `:1993` (the agent's wss+OAuth2 listener, bound `0.0.0.0` today), saved | forces every operation through the reverse channel, and survives reboot |

None of these depend on a running agent. The agent is needed **only to
un-seal in-band** (over the reverse PTY). If the agent crashes, is SIGKILLed,
or fails to start, the box **stays sealed**. Step 1 (`disable sshd`) is the
primary lever, because it cuts every user at one time and not key by key. The
key-move is a second layer of defense.

Corollary: sealing is durable across reboot *by construction*. The agent does
**not** re-seal on startup, because the OS already holds the line.

### 2.1 The access doors: three SSH-less paths, and the mutual-update rule

Verified 2026-07-24 against the live artgins fleet. Excluding SSH (which is only
for hand-over / provisioning — "the machine is given to us"), a node is
reachable **three** ways, and they are **not** equivalent:

| # | Door | Endpoint | Reaches | Surface |
|---|------|----------|---------|---------|
| 1 | Direct to the agent | `wss://<node>:1993` (OAuth2) — **inbound** | the node's own `yuneta_agent` | full `C_AGENT` |
| 2 | Controlcenter, agent plane | `wss://<cc>.com:1996` → `command-agent agent_id=<node> cmd2agent=…` | `yuneta_agent` (`C_AGENT`) | **full**: deploy, lifecycle, realms, configs, snaps, `command-yuno`, `dir-*`, `read-*`, cert-sync, `open-console`, … |
| 3 | Controlcenter, agent22 plane | `wss://<cc>.ovh:1997` → `command-agent …` | `yuneta_agent22` (`C_AGENT22`) | **minimal**: `help`, `list/open/close-console`, `write-tty` — in practice **only the root shell** |

- **Door 1 (`:1993`) is inbound** and open to `0.0.0.0` today — it is the one
  the seal must close (step 4 in the table above). Doors 2 and 3 are the node's
  **outbound** reverse channels to the controlcenter and are what survives the
  seal: you never connect *to* a sealed node, it reaches out and you ride its
  channel back down through the controlcenter.
- **The two controlcenter planes reach different agents with different
  surfaces.** `.com` with `agent` is full node management. `.ovh` with `agent22` is a
  deliberately minimal escape hatch that offers **only the console**. So the
  redundancy is "a full agent + a bare-shell twin", not two equal copies. (On
  our 7.8.x nodes the homing is clean: `.com`→`yuneta_agent`,
  `.ovh`→`yuneta_agent22`. Legacy 6.x nodes can home differently.)
- **The controlcenter is a multi-tenant hub.** One CC (artgins) is the
  reverse-channel endpoint for *many* independent fleets (start-fleet, the
  normedan hospital nodes, a CESGA cluster of ~15, raspz, …). Any operator it
  authenticates with the `command-agent` permission can address every agent
  registered there. **Under a seal the controlcenter's security IS the whole
  fleet's security** — it needs the strongest hardening in the network. (This is
  compounded by the per-command gate being off on the nodes — see [§5.4](#54-the-ssh-dependency-ledger):
  authenticated ⇒ full non-gated surface on every node.)

#### The mutual-update rule — never saw off your own branch

The two agents exist so **each can maintain the other**. The agent is a
standalone daemon (not a managed yuno), so updating an agent binary is a
`--stop` / replace / `--start` run **on the box, from a root console** — and you
must open that console through the *other* agent's plane:

- To update / restart **`yuneta_agent`** → open a console through **agent22**
  (`.ovh:1997`) and do the stop/replace/start there. If instead you entered
  through the agent's own plane (`.com:1996`) and killed it, you kill the very
  reverse channel your session rides on and **lose the node**.
- To update **`yuneta_agent22`** → open a console through **`yuneta_agent`**
  (`.com:1996`).
- Rule: **you can never kill or restart the agent whose reverse channel carries
  your session.** Cross over — maintain each agent from the other's plane. This
  is the concrete operational reason for the two-agent design (mutual,
  cross-channel maintenance) and the sharp edge behind "never stop or upgrade
  both agents at once": doing so, or killing the one you came in on, strands the
  node with no in-band channel, recoverable only from the provider console
  ([§6](#6-break-glass-the-provider-console-is-the-contract)).

---

## 3. Command surface

Three agent commands, invoked **from the controlcenter over the reverse
channel** (and, for `node-seal-status`, locally over `:1991`):

| Command | What it does |
|---------|--------------|
| `seal-node [force=false]` | Run the [precondition gate](#4-the-seal-is-self-proving-and-self-validating), then apply the three OS-persistent steps. Records which steps it applied. |
| `unseal-node` | Reverse exactly the recorded steps. Only reachable while the reverse channel is alive. |
| `node-seal-status` | Report current seal state, the steps applied, and the last verification snapshot. |

`unseal-node` is **not** a recovery path for a dead channel — if the reverse
channel is down there is no way to deliver it. That case is the provider
console ([§6](#6-break-glass-the-provider-console-is-the-contract)).

---

## 4. The seal is self-proving AND self-validating

Two independent safety layers, because there is no automatic recovery:

**Self-proving.** `seal-node` arrives *through* the controlcenter→agent reverse
channel. If it executes and ACKs back, the channel demonstrably works at the
instant of sealing. You only ever close the forward door by sending a command
through the door that replaces it.

**Self-validating (the hard gate).** `seal-node` **refuses** unless *all* hold:

1. `node_owner != "none"`.
2. `controlcenter.jwt` present and **not expired** (the agent introspects its
   own `exp` claim).
   3. The controlcenter link of the agent is established. The session is
   connected, not only configured.
4. **agent22 is also connected** to its controlcenter — redundancy is present.
   See the heartbeat in [§5.2](#52-the-agent22agent-heartbeat). This is the one
   new piece that the gate needs.
5. `authz.jwks` present and the `tcps` client certs present.
6. A recent PTY self-test round-trip succeeded (or the live PTY session the
   command was issued from).

`force=true` bypasses the gate but logs *who* and *why* (the
[no-silent-errors axiom](../../../CLAUDE.md) applies). With no safety net,
`force` is "on your head".

---

## 5. The plumbing this exposes (must land first)

### 5.1 Hardening #0: agent22 must never suicide

Today [`c_agent22.c`](../yuno_agent22/src/c_agent22.c) `mt_create` does
`exit(0)` when `node_owner` is empty. The primary agent in the same situation
writes `"none"` and **keeps running** ([`c_agent.c`](src/c_agent.c) `mt_start`).
For a black box this asymmetry is dangerous: if a provisioning mistake clears
`node_owner`, the escape hatch kills itself **exactly when you need it most**.

**Fix:** agent22 must behave like the primary. It must return to standalone
`"none"` mode (quiet, no controlcenter) and **stay alive**. The escape hatch
never exits on a config value it can default. This is self-contained and
low-risk, and it is the prerequisite for everything else here.

### 5.2 The agent22↔agent heartbeat

The seal gate needs to know "is agent22 connected to its controlcenter?".
agent22 is **outbound-only** (it opens no listening port), and the two agents
talk to *different* controlcenters (`.com` vs `.ovh`), so the primary's
controlcenter cannot answer the question. The signal must be **node-local**:

> agent22 writes a small heartbeat — its CC-connection state + a timestamp —
> to a node-local sink the primary agent reads (a file under `/yuneta/agent/`
> or a record in the agent treedb). The primary derives "agent22 connected =
> last_seen within N seconds".

This mirrors the existing in-memory-presence pattern used elsewhere in the
fleet (touch-a-timestamp, derive liveness). It is the only new inter-process
channel the design introduces.

### 5.3 Expiry is a guaranteed brick

Sealed box + short-lived JWT or cert = a guaranteed eventual brick: the reverse
channel dies on expiry and the only way back is the console. Design rule for a
sealed node: **`controlcenter.jwt` and the `tcps` certs must be long-lived or
auto-renewed**, never short. (Today's agent JWT carries `exp` in 2038 —
effectively perpetual — and `cert_sync_*` already auto-renews the Let's Encrypt
path. Keep it that way. See [`YUNO_AUTH.md`](YUNO_AUTH.md).)

### 5.4 The SSH-dependency ledger

**The seal is blocked by whatever still needs SSH.** Sealing a node is not a
switch to flip — it is the moment every remaining SSH dependency turns into an
outage. So this ledger is a standing inventory, kept current: *policy is
agent-first — if the agent can do it, do it through the agent, and every time
something genuinely cannot be done through the agent, it gets recorded here.*

**Verified end-to-end SSH-less (2026-07-24).** The whole reverse-channel path
was exercised with a real non-owner operator identity (`claudia@artgins.com`,
root in the controlcenter, **not** in any node's authz), no SSH anywhere:
operator → `wss://<cc>:1996` (OAuth2 password grant, client `yunetacontrol`) →
`command-agent agent_id=<node> cmd2agent=<cmd>` → node agent. Results on the
wattyzer agent:

- `list-agents`, and `command-agent … cmd2agent=list-yunos` → **worked**
  ("Command sent to 1 nodes" + the table). So **every Tier-1 command runs for
  any operator the controlcenter authenticates** — because the per-command
  `SDF_AUTHZ_X` gate is **off** on the node (`enable_command_authz` absent), so
  authentication alone is enough. The agent does not read the authz list of
  the node.
- `command-agent … cmd2agent=open-console` → **`No permission to 'open-console'
  in service 'agent'`**. The *only* command that refused, because
  `cmd_open_console` calls `gobj_user_has_authz` unconditionally and claudia is
  not in the node's authz.

**Two consequences for the seal.** (1) With the per-command gate off, the
security boundary of a sealed node collapses to *"who can authenticate to the
controlcenter"* plus *"who is in the node authz for `open-console`"* — the node
authz list governs the kitchen and nothing else. (2) To give a principal full
"to-the-kitchen" access you must add them to **each node's** authz (see the
identity model in [`IPC.md`](IPC.md) §4.7). `yuneta_admin@artgins.com`, a seed
immutable admin on every node, already has it, so it is today's working
full-access operator over the reverse channel.

Turning this into a real boundary (enable the gate + a role→permission matrix)
is deliberately deferred to a **single final phase** — the running backlog of
every place needing a role/permission check lives in
[`YUNO_AUTH.md`](YUNO_AUTH.md) §4.10.

The distinction below is **not** "possible / impossible" but which of three
tiers a task falls in:

**Tier 1 — a structured agent command exists.** Safe under a seal today.
Verified against [`c_agent.c`](src/c_agent.c) (2026-07-22 command surface):

- Yuno lifecycle: `run-yuno`, `kill-yuno`, `play-yuno`, `pause-yuno`,
  `enable-yuno`, `disable-yuno`, `create-yuno`, `delete-yuno`,
  `find-new-yunos`.
- Binaries: `install-binary`, `update-binary`, `delete-binary`,
  `replicate-binaries`.
- Configs / realms: `create-config`, `edit-config`, `update-config`,
  `view-config`, `delete-config`, and the `*-realm` family.
- Snapshots: `shoot-snap`, `activate-snap`, `deactivate-snap`, `snaps`,
  `snap-content`.
- Observability: `read-file`, `read-binary-file`, `read-json`, the `dir-*`
  family (`dir-logs`, `dir-realms`, `dir-store`, …), `top`, `stats-yuno`,
  `trace-on-yuno` / `trace-off-yuno`, `command-yuno`.
- Fleet: `replicate-node`, `upgrade-node` — note these replicate **realms'
  yunos**, they are *not* an OS package upgrade.
- Certs: `cert-sync-now`, `cert-sync-status`.

The deploy tooling already lives in this tier: `sync_binaries`, `sync_configs`
and `set_start_priorities` (`yunetas.agent_tools.*`) drive a remote `wss://`
agent through `ycommand` with a single OAuth2 password-grant login, reusing the
JWT via `-j`. They were written for SSH-disabled nodes on purpose.

**Tier 2 — only through the agent's PTY** (`open-console` + `write-tty`, the
same channel the `gui_agent` Terminal workspace uses). Reachable under a seal,
but as free-form shell text: no schema, no authz granularity beyond the
`open-console` permission, nothing an operator can audit as a command. Anything
here is a candidate for promotion to Tier 1:

- Writing an **arbitrary file** to the node. There is `read-file` but **no
  `write-file`**. Only binaries and configs have structured write commands. So
  certs, `/etc/security/limits.d/*.conf`, `resolv.conf`, nginx vhosts, secret
  overlays all fall here.
- **Publishing a SPA bundle** into a vhost (`rsync dist/ → /yuneta/gui/<fqdn>/`).
  Same gap as above and today's most frequent one: every front-end release of
  yunovatios, wattyzer and the treedb GUI goes this way. It is also the one that
  bites first when a node is reimaged — on 2026-08-27 the yunovatios controller's
  SSH host key had changed and the deploy hung at the verification prompt, while
  every backend task of the same session (binaries, configs, snapshots, commands)
  went through the agent untouched. A `write-file`, or a vhost-aware command,
  would promote it to Tier 1.
- OS administration: `systemctl`, `nftables`, `sysctl`, `tmpfiles.d`,
  `core_pattern`/apport, certbot renewals, nginx/openresty reload.
- Installing or upgrading the **`.deb`/`.rpm` package itself** (SDK + agent
  binaries). `upgrade-node` does not do this.
- Upgrading **the agent binary**. The agent is a standalone daemon, not a
  managed yuno: `update-binary` does not reach it, and it is restarted with
  `yuneta_agent --config-file=<json> --stop` then `--start`. The intended
  agent-side answer is the twin (each agent can replace the other — never both
  at once), but there is no command for it today.

**Tier 2 is reachable — and it is root.** Resolved on `e.com` 2026-07-22:

- [`c_pty.c`](../../../kernel/c/root-linux/src/c_pty.c) spawns the shell with
  `forkpty()` + `execv()` and **no `setuid`/`setgid`** — the PTY child inherits
  the agent's own uid.
- On that node both agents run as **`yuneta`**, and `yuneta` has
  `(ALL) NOPASSWD: ALL`.

So the PTY is a passwordless-root shell, and every OS-level item above really
is doable under a seal: a sealed node can be *maintained*, not merely observed.
The price is that **`open-console` is effectively root on the box** — under a
seal it is the only door, and that door is total. Two consequences:

- The `open-console` permission is the entire security boundary of a sealed
  node. It must never be granted as a convenience.
- Promoting Tier 2 items to Tier 1 is worth real effort — a structured
  `write-file`, a package-upgrade command — because each promotion is one
  fewer reason to hand out root.

Verified on `e.com` only. Confirm it per node before you seal, especially the uid
the agent runs as (the local dev node, for one, runs the primary agent as the
developer's own user).

**The two authz paths are not the same, and the asymmetry is deliberate:**
`cmd_open_console` calls `gobj_user_has_authz(gobj, "open-console", …)`
**unconditionally** ([`c_agent.c`](src/c_agent.c)), so the console is gated by
the `Authz.initial_load` user list whatever else is configured. The generic
per-command gate is different: `SDF_AUTHZ_X` runs only when the yuno sets
`enable_command_authz` (see
[`command_parser.c`](../../../kernel/c/gobj-c/src/command_parser.c)), and that
attr is **absent on `e.com`** — so the rest of the surface (`install-binary`,
`kill-yuno`, `read-file`, …) is **authenticated but not authorized**: any
principal holding a valid realm token can drive it. Before sealing, decide
whether that is acceptable, because the only remaining defense of a sealed node is
this gate. Tracked with the wider role-matrix work in
[`YUNO_AUTH.md`](YUNO_AUTH.md) §4.5.

**Tier 3 — genuinely SSH-only.** Nothing is known to be here yet, *because*
Tier 2 exists: the PTY is a shell, so a shell-shaped task is never impossible,
only unauditable. Treat this tier as the alarm — an entry here means the seal
cannot be applied to that node until it is resolved or accepted as a
console-only (break-glass) operation.

**Who reaches the node through the reverse channel:** the operator, not the
system user. The agent's own JWT identity (`yuneta_agent@…`) authenticates the
*link* into the controlcenter, but the operator's `__username__` is propagated
end to end and is what the node's `authz_checker` evaluates — so every
principal expected to run authz-gated commands on a sealed node must exist in
**that node's** authz list, not only in the controlcenter's. Full chain,
measurement method and the client-side trust asymmetry in
[`IPC.md`](IPC.md) §4.7.

**Resolved (2026-08-25): the CLI's node registry no longer assumes SSH.**
`~/.yuneta/nodes.json` now carries, per node, `url` (`wss://<host>:1993`),
`issuer`, `client_id`, `user_id` and the TLS pinning pair
(`ssl_trusted_certificate`, `ssl_server_name`); `ssh` is an optional fallback
used only by `--tunnel`, and `url` wins when both are present. The two
yunovatios nodes are registered with a `url` and **no** `ssh` field at all, so a
sealed node is already registrable. Note the client_id is the **node's own**
(`controlador.yunovatios.es`), not the SPA's (`yunetacontrol`, auth-code-only
and therefore useless headless) — picking the wrong one is what makes a
headless login hang rather than refuse.

### 5.5 An internal yuno needs its own `C_AUTHZ`, or the seal locks it out

**Found 2026-08-25 on `yunovatios-controlador` and `yunovatios-central`.**
`authz_checker()` ([`c_authz.c`](../../../kernel/c/root-linux/src/c_authz.c))
opens with `gobj_find_service_by_gclass(C_AUTHZ)` and, finding nothing, denies
**everyone** — fail-closed by design. Three yunos had no such service:
`db_tracks_co` (controlador), `db_tracks_ce` (central) and `gate_caudal`
(controlador). Every authz-guarded command answered `-403 No permission` to
every principal, `yuneta_admin@artgins.com` with the `root` role included. Not
a permissions problem: there was nobody to read the permissions. Their own logs
said it plainly — `{"function": "authz_checker", "msg": "No gclass authz
found"}`.

None of the three listens outside its node (`tcp://127.0.0.1:5030`,
`tcp://127.0.0.1:5020`, and loopback + `/dev/ttyUSB0` respectively), so **SSH
was the only way to touch them**. Under a seal there would have been none at
all — a Tier-3 entry created by omission rather than by a missing command, and
invisible until someone tries.

The fix is per-yuno configuration, not framework: declare the `authz` service
(`C_AUTHZ`, priority 0) and point it at the node's authzs store as a
**follower** (`Authz.master: false`, `Authz.authz_service: <store>`). The
store's master stays the yuno that carries `Authz.initial_load`
(`db_history_co.1630` / `db_history_ce.1620`). `master` governs who may WRITE
the authz store — it is not a privilege level, and a follower authorizes just
as well as a master.

**Sealing prerequisite.** Before sealing a node, check that every yuno with no
public listener actually has the service. `command-yuno id=<id>
service=__yuno__ command=services` names it; a yuno that answers `-403` to a
plain `list-keys` or `topics` is the symptom.

**Live confirmation of the identity propagation asserted above.** With
`C_AUTHZ` running, the yuno's own trace (`set-gclass-trace gclass=C_AUTHZ
level=messages`, driven over `wss://<node>:1993` with no SSH) reads:

```
user 'yuneta_admin@artgins.com', service: 'C_TRANGER^tranger_tracks',
authz 'list', allow -> YES
```

So the operator's identity does reach the *internal* yuno, not only the agent
— and it is super-user today because of what the store holds (`yuneta`,
`yuneta_admin@artgins.com` and `claudia@artgins.com` all carry
`roles^root^users` = `realm_id:*, service:*, permission:*`), not because of the
architecture. A narrower role is possible without touching any code.

**PENDING — how an SPA user acts on an internal yuno.** Two doors exist today
and the choice is not yet made:

- **(a) A public service on the yuno.** `gate_enchufe` (estadodelaire,
  hidraulia) and `gate_energia` (yunovatios) declare
  `public_services: ['__top_side__']` with a `service_descriptor`, so the SPA
  opens a session **straight to the yuno** and calls `set-power` — the agent is
  not in the path. It works, it is in production, and it is the one that gets
  reached for by default. It also un-seals that yuno quietly: the yuno stops
  being internal for that service, and `cmd_set_power` carries no
  `gobj_user_has_authz` guard, so the authenticated session **is** the
  permission.
- **(b) Through the agent (`command-yuno`), the yuno staying internal.**
  Reachable now that `C_AUTHZ` is in place, and the real user arrives, so
  per-user roles work. It only becomes a *boundary* once
  `enable_command_authz` is on AND roles narrower than `root` exist — both of
  which belong to the single final phase tracked in
  [`YUNO_AUTH.md`](YUNO_AUTH.md) §4.10.

Recorded here rather than only in that backlog because door (a) is a *sealing*
decision disguised as an application one: every service published that way is
one more inbound port the seal has to keep open.

---

---

## 6. Break-glass: the provider console is the contract

Because recovery is out-of-band by choice, it must be a documented,
tooled procedure — not folklore:

- **`/yuneta/bin/yuneta-unseal-local`** — a standalone script an operator runs
  as root *from the provider console* (KVM / rescue / serial). It reverses the
  three seal steps **without needing the agent or the network**: re-enable
  sshd, drop the nftables block, restore `authorized_keys.sealed`, unlock the
  bootstrap user. It reads the same recorded step-list `seal-node` wrote.
- **Per-provider runbook** (OVH first): boot into rescue mode → mount the
  system disk → either run `yuneta-unseal-local` chrooted, or restore
  `authorized_keys.sealed` and `systemctl enable ssh` by hand.

There is **no** dead-man's-switch and **no** auto-unseal watchdog. A sustained
controlcenter outage does **not** reopen SSH. The box stays black, and the
console is the only fallback. (The alternative, an auto-unseal after N hours
offline, was considered and rejected for OVH nodes: it trades the brick risk
for a
recurring SSH-reopen security window, and the OVH console is trusted.)

---

## 7. Node state (phase 2)

Persist a node lifecycle state in the agent treedb so the controlcenter can
show a **fleet view** — which nodes are still SSH-open vs sealed:

```
bootstrapped  →  provisioned  →  verified  →  sealed
  (host SSH)      (project        (CC + PTY    (SSH closed,
                   config on        proven on    reverse PTY
                   both agents)     both)        only)
```

Record: `{ state, sealed_at, sealed_by, steps_applied, last_verify }`.
`seal-node` is the `verified → sealed` transition. The controlcenter must issue
it only after it saw independently that **both** agents are registered and that
a PTY round-trip succeeded. The human never decides "is it safe to close SSH" — the
controlcenter proves it, then seals.

---

## 8. End-to-end flow

```
1 bootstrap   install.sh over the hosting's SSH/user (cloud-init ideal)
              → agents run standalone (node_owner=none): black, no reverse channel
2 provision   push the project agent JSONs to agent AND agent22 → restart
              → both dial their controlcenter (.com:1994 / .ovh:1995)
3 verify      controlcenter sees BOTH registered + a PTY round-trip OK
4 seal        controlcenter issues seal-node → gate passes → OS seals → ACK
              → from here: reverse PTY only; recovery = OVH console
```

The bootstrap key is **ephemeral by construction**: it exists only to run
install + provision (ideally via cloud-init user-data, so no human SSHes in for
a normal node), and the `seal` step retires it. The forward door is temporary
by design, not by discipline.

---

## 9. Build order

0. **Keep the SSH-dependency ledger current** ([§5.4](#54-the-ssh-dependency-ledger)) — continuous, not a step: it is the readiness gate for every node. Work agent-first and record each new SSH-only need as it appears.
1. **Hardening #0** — agent22 stops suiciding on empty `node_owner` ([§5.1](#51-hardening-0-agent22-must-never-suicide)). Self-contained, unblocks the rest.
2. **agent22↔agent heartbeat** ([§5.2](#52-the-agent22agent-heartbeat)) — the gate's precondition #4.
3. **`seal-node` / `unseal-node` / `node-seal-status`** ([§3](#3-command-surface)) — the three OS-persistent steps + the gate ([§4](#4-the-seal-is-self-proving-and-self-validating)).
4. **`yuneta-unseal-local`** + the OVH break-glass runbook ([§6](#6-break-glass-the-provider-console-is-the-contract)).
5. **Node-state topic + fleet view** ([§7](#7-node-state-phase-2)) — phase 2.

---

## See also

- [`YUNO_AUTH.md`](YUNO_AUTH.md) — controlcenter JWT, `C_AUTHZ`, cert auto-sync (the credentials a sealed node lives or dies by).
- [`IPC.md`](IPC.md) — `C_IEVENT_CLI` outbound, the gate stack, how the reverse channel is built.
- [`../yuno_agent22/`](../yuno_agent22/) — the redundant escape-hatch agent.
- [`ENTRY_POINT.md`](ENTRY_POINT.md) — the `ydaemon.c` watcher (the autonomous-survival kernel the heartbeat and seal sit on top of).
