#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::UNIX;
use JSON::PP;
use Getopt::Long;
use Fcntl qw(O_RDONLY O_WRONLY O_CREAT O_EXCL O_NOFOLLOW);	# 10.E3 S3: sysopen(0600) for --out; 10.E4 S3: staging copy

# Never die from a write to a peer that closed mid-exchange. A daemon that
# rejects our connection at the peer-ACL gate (uid != 0) or crashes mid-request
# closes the socket, and a bare `print $sock` would then deliver SIGPIPE and
# kill the tool (exit 141) instead of letting us report that daemon as
# unreachable. The daemons themselves SIG_IGN SIGPIPE (daemon_base.c); status
# must be at least as robust. Writes to a closed socket now fail with EPIPE,
# which `_probe`/`daemon_call` surface as "no response".
$SIG{PIPE} = 'IGNORE';

my $sock_path = '/var/www/run/ogmaprotect.sock';
my $netd_path  = '/var/run/ogmaprotect-netd.sock';
my $pfd_path  = '/var/run/ogmaprotect-pfd.sock';
my $sysd_path  = '/var/run/ogmaprotect-sysd.sock';   # 10.E3.2 get_os_release probe
# 10.E8.1 S3: the three NON-netd window-producer sockets. Read-only probe
# targets for the domain-scoped resolver verbs only (§7 D10 `P-direct`): the
# TERMINAL confirm/cancel request still goes to authd's uid-0 arm like every
# other window verb -- see the $peer selection below. Values are
# OGMA_SOCK_RTD / _DNSD / _ROUTED (daemon/common/ogmaprotect.h).
my $rtd_path    = '/var/run/ogmaprotect-rtd.sock';
my $dnsd_path   = '/var/run/ogmaprotect-dnsd.sock';
my $routed_path = '/var/run/ogmaprotect-routed.sock';
# 10.E8.1 S4: the fifth and last window-producer socket. OGMA_SOCK_IPSECD
# (daemon/common/ogmaprotect.h) -- note the `d`: the literal already ships in
# @STATUS_DAEMONS above, and the two must not diverge.
my $ipsecd_path = '/var/run/ogmaprotect-ipsecd.sock';
my $direct_netd;
my $direct_pfd;
my $session;
my $path;
my $out;
my $staged_sha;
# 10.E4 S3 (§7 D4): `backup stage --file` -- the source bundle to copy into the
# staging uploads dir. Declared HERE, with the other file-scope options and
# ABOVE the early-exit dispatch: t/ogmaprotectctl_status.t fails by name on any
# column-0 `my $var;` below the first `exit do_*()`, because `status`/`schema`/
# `support-bundle`/`help` exit before a later statement would ever run, leaving
# such a variable undef inside them.
my $file;
my $json_out;
my $no_drift;
my $sockets_only;
my $timeout;
# 10.E8 S3: cert break-glass options. --confirm-timeout is the ROTATION
# auto-revert window (seconds), NOT --timeout, which is the socket deadline and
# was already taken. The cert domain clamps it to 60..900
# (OGMA_CERT_CONFIRM_TIMEOUT_MIN/_MAX).
my $cert_cn;
my $cert_san;
my $cert_days;
my $confirm_timeout;
# 10.E4 S4 (`K-verb`, Gate-0 §7 D14): proceed with a bundle whose integrity this
# box cannot attest -- the rebuilt-appliance case, where the old deployment's
# .cap-master is gone and its own backup classifies INVALID. Per-invocation and
# root-console only; authd refuses it on any session arm.
my $accept_foreign;
my $want_help;

GetOptions(
    'direct-netd'     => \$direct_netd,
    'session=s'      => \$session,
    'path=s'         => \$path,
    'out=s'          => \$out,
    'staged-sha256=s' => \$staged_sha,
    'file=s'         => \$file,
    'json'           => \$json_out,
    'no-drift'       => \$no_drift,
    'sockets-only'   => \$sockets_only,
    'timeout=i'      => \$timeout,
    'cn=s'           => \$cert_cn,
    'san=s'          => \$cert_san,
    'days=i'         => \$cert_days,
    'confirm-timeout=i' => \$confirm_timeout,
    'accept-foreign' => \$accept_foreign,
    # `use Getopt::Long;` is bare, so auto_help is OFF and no declared option
    # starts with 'h' -- before this, `--help` and `-h` died with
    # "Unknown option: help" and exit 1, printing no usage at all. At 3am the
    # reflex is --help, and this tool's whole S3 purpose is break-glass.
    'help|h'         => \$want_help,
) or exit 1;

if ($want_help) {
    usage(\*STDOUT);
    exit 0;
}

# I-02 `ogmaprotectctl status`: the control-plane daemon LIVENESS list — every
# daemon's root-only Unix socket (peer ACL uid==0), pinged once for up/down. The
# socket paths mirror the OGMA_SOCK_* macros in daemon/common/ogmaprotect.h (kept
# in sync by hand, as the three $*_path vars above already are). gwmond is
# socketless and handled separately.
#   [ name, socket ]
# 10.C6 S7b (M13d): the DRIFT-domain walk is NO LONGER driven from here — it is
# driven from the generated manifest (OGMA_DRIFT_MANIFEST = ops.c {drift_role !=
# NONE} JOINed to daemon_inventory sockets), loaded FAIL-CLOSED by
# _load_drift_manifest. This list is now PURELY the liveness ping list (incl. the
# liveness-only daemons authd/diagd/healthd/logd that report no drift domain) +
# the C9 §11 name-parity source. The drift pass reuses each daemon's $is_up by
# NAME; the t/ successor pins {manifest daemons} ⊆ {these names} + per-name socket
# equality, so a drift daemon can never be walked without a liveness entry and the
# hand-maintained drift columns that used to live here are gone (a drift op cannot
# enter the walk without a registry row, nor a row without being walked).
# OGMA-C9-INVENTORY: status_daemons (name = first field; gwmond excluded, socketless)
my @STATUS_DAEMONS = (
    [ 'authd',   $sock_path                          ],
    [ 'netd',    $netd_path                          ],
    [ 'rtd',     '/var/run/ogmaprotect-rtd.sock'     ],
    [ 'pfd',     $pfd_path                           ],
    [ 'dnsd',    '/var/run/ogmaprotect-dnsd.sock'    ],
    [ 'dhcpd',   '/var/run/ogmaprotect-dhcpd.sock'   ],
    [ 'routed',  '/var/run/ogmaprotect-routed.sock'  ],
    [ 'diagd',   '/var/run/ogmaprotect-diagd.sock'   ],
    [ 'healthd', '/var/run/ogmaprotect-healthd.sock' ],
    [ 'arpd',    '/var/run/ogmaprotect-arpd.sock'    ],
    [ 'logd',    '/var/run/ogmaprotect-logd.sock'    ],
    [ 'timed',   '/var/run/ogmaprotect-timed.sock'   ],
    [ 'ipsecd',  '/var/run/ogmaprotect-ipsecd.sock'  ],
    [ 'sysd',    '/var/run/ogmaprotect-sysd.sock'    ],
    [ 'alertd',  '/var/run/ogmaprotect-alertd.sock'  ],
);

# ---- 10.C6 S7b: the generated drift-domain manifest (M11/M13d) --------------
# These three constants are declared HERE, ABOVE the `status` dispatch, ON
# PURPOSE. A file-scope `my $X = ...` runs its initializer only when execution
# REACHES the statement, and `status` bails out at `exit do_status()` below
# before any statement further down runs. When these lived next to their helpers
# (further down, with the rest of the drift-manifest machinery) they were still
# undef on every real `status` invocation -- $..._DEFAULT undef rendered "drift
# manifest unreadable ()" and $..._MAX_VER undef rendered a bogus "schema_version
# newer than this tool (max )". Keep them above the status dispatch. (Regression
# pinned end-to-end by t/ogmaprotectctl_status.t.)
#
# The runtime source of the drift-domain walk: a pure-JSON file generated from
# ops.c {drift_role != NONE} JOINed to daemon_inventory sockets (gen_drift_
# registry). It REPLACES the hand-maintained @STATUS_DAEMONS drift columns as the
# walk's source, so a drift-reporting op cannot be walked without a registry row
# nor be in the registry without being walked. Installed 0644 root:wheel; parsed
# with JSON::PP, NEVER require'd (M11a — no root code-exec surface).
my $OGMA_DRIFT_MANIFEST_DEFAULT = '/etc/ogmaprotect/drift_registry.json';
# The highest manifest schema_version this CLI can parse — moves in lockstep with
# OGMA_DRIFT_MANIFEST_VERSION in daemon/common/drift.h. A newer manifest fails
# closed (you cannot trust a format you cannot parse).
my $OGMA_DRIFT_MANIFEST_MAX_VER = 1;
# Build-stamped canonical required set: the sorted "daemon:op:domain" triples of
# ops.c {drift_role != NONE}, substituted by the Makefile from
# `gen_drift_registry --required-set` (the 0.5.5 seam). The CLI requires
# it as a SUBSET of the loaded manifest, so a truncated/short manifest MISSING a
# specific domain (ipsec/cert/alerts/...) fails closed instead of walking fewer
# domains and reading exit-0 healthy. Left as the literal placeholder in the
# un-installed repo/test copy (the subset check then no-ops; the t/ successor +
# check-drift-registry pin the census independently).
my $OGMA_DRIFT_REQUIRED = 'alertd:get_alerts:alerts alertd:get_remotelog:remotelog arpd:arp_list:arp dhcpd:get_dhcp_server:dhcp dnsd:get_dns:dns ipsecd:get_ipsec:ipsec netd:get_carp:carp netd:get_net:interfaces netd:get_pfsync:pfsync netd:get_pppoe:pppoe netd:get_tunnel:tunnel netd:get_wireguard:wireguard pfd:get_pf:pf routed:get_routing:routing rtd:get_routes:routes sysd:get_cert:cert sysd:get_identity:identity timed:get_time:time';

# ---- 10.C9 / L1-22: boot-order dependency pairs -----------------------------
# ALSO declared above the dispatch, for the same reason as the three constants
# above — it was missed when they were hoisted, and the consequence was worse
# than theirs. `rcctl enable` records the boot order in /etc/rc.conf.local's
# pkg_scripts=; a wrong order (e.g. authd before netd) leaves the management
# plane dead after the NEXT reboot. That is not a current outage, so it does NOT
# change the status exit code — it is a heads-up only.
#
# While this lived below `exit do_status()` its initializer never ran on a real
# `status`, so _bootorder_verdict looped over an EMPTY dependency list and fell
# through to `state => 'ok'`. Not merely vacuous: an affirmative green for any
# boot order, including the broken one this check exists to catch. The perl
# test never saw it because it evals the helper block, which RUNS the
# declaration. Pinned two ways now: the generic no-file-scope-my-below-the-
# dispatch scan in t/ogmaprotectctl_status.t, and a direct assertion that
# _bootorder_status() sees a non-empty pair list.
#
# The pairs are reconciled against the daemon inventory by
# check-daemon-inventory.sh, which awk-scans for the marker below (position-
# independent, so this move is safe); the parse + verdict logic is unit-tested
# on the fast perl CI leg.
# OGMA-C9-INVENTORY: boot_deps (dependent -> must-boot-first; short names)
my @BOOT_DEPS = (
    [ 'authd',  'netd' ],
    [ 'arpd',   'netd' ],
    [ 'gwmond', 'rtd'  ],
);

# ---- 10.E6 VD-E6-22: the installed-state release-match self-check -----------
# The signed package is RELEASE-SPECIFIC: OpenBSD bumps shared-library MAJORs
# between releases and ld.so wants an exact match, so a package built for one
# release installs and VERIFIES cleanly on another and then every daemon dies
# with `can't load library`. pkg_add cannot refuse it (verification precedes
# unpack; @arch pins the CPU, not the OS release), and NO DAEMON can report it
# -- the daemon that would run the check is the thing that cannot start. The
# only surfaces left standing are base perl and sh: this CLI and the
# provisioner. So `status` reads the in-package RELEASE-INFO (written by
# `make install`, transitively signed in the release manifest) and compares its
# `built_for_openbsd` with the RUNNING kernel's release -- installed state
# against installed state, never a pkg_add exit code (VD-E6-15). ADVISORY: it
# never feeds $healthy (a mismatch already leaves every daemon DOWN, which
# does); its job is to NAME the cause on the one line an operator will read.
# Hoisted above the dispatch like its siblings (the @BOOT_DEPS lesson).
my $OGMA_RELEASE_INFO = '/etc/ogmaprotect/RELEASE-INFO';

my @args = @ARGV;
unless (@args >= 1) {
    usage();
    exit 1;
}

my $op = shift @args;
my $req = {};

# Substituted by the Makefile from OGMAPROTECT_VERSION in ogmaprotect.h.
if ($op eq 'version') {
    print 'ogmaprotectctl 0.5.5' . "\n";
    exit 0;
}

# I-02: read-only status of every control-plane daemon socket + a per-domain
# config-drift summary. Sessionless root-console tool (like `pf confirm` without
# --session): probes each daemon socket directly as root and works even when
# authd / the web plane is down. Exit: 0 healthy, 1 degraded, 2 cannot-run.
if ($op eq 'status') {
    exit do_status();
}

# 10.E2 S2 (L6-04 T3): per-fragment config schema state. Sessionless root-console
# read over authd's uid-0 get_schema_state arm — authd is the one socket daemon
# that unveils the config dir. An EARLY-EXIT verb like `status` (not a member of
# the if/elsif chain below) because the Gate-0 contract requires the alarm-bounded
# _probe: the chain's shared tail uses daemon_call, which has NO timeout, and a
# wedged authd would hang a monitoring poll forever.
#
# NOTE the same file-scope initializer hazard `status` documents above: this exits
# before ANY `my` declared below runs, so do_schema may only read globals declared
# ABOVE this line ($sock_path, $json_out, $timeout) and must introduce no new
# file-scope state of its own.
if ($op eq 'schema') {
    exit do_schema();
}

# 10.E3 S3 (L6-05 L3 / Gate-0 §7 D20): produce a support bundle from the root
# console. Sessionless -- authd answers support_bundle on its uid-0 arm before
# the session gate -- so this works when the web tier is down, the management
# certificate has expired, or users.db is corrupt. It does NOT survive a wedged
# authd: the bundle is an authd job (VD-E3-14).
#
# D20 declined a standalone `audit-export` verb; this verb discharges the CLI
# half of L6-05's L2 clause on its own.
#
# An EARLY-EXIT verb like `status`/`schema`, and subject to the same file-scope
# initializer hazard they document: it exits before any `my` declared below runs.
if ($op eq 'support-bundle') {
    exit do_support_bundle(@args);
}

# 10.E8.6 S1 (Gate-0 §7 D1 `W-ctl-verb`): the consolidated armed-window report.
#
# ONE surface that names every open confirm window WITH its txn_id -- the
# VD-E8-57 flip condition. Sessionless and root-local, composed ENTIRELY from
# reads that already ship: no new op, no new wire field, no schema bump, and
# ZERO growth in authd's pre-session admit set (§4 F1/F3). The wedge this
# exists for is a console scenario by construction -- the pending change is
# what destroyed the session the web resolver would need.
#
# An EARLY-EXIT verb of the status/schema/support-bundle class, and subject to
# the same file-scope initializer hazard they document: it exits before ANY
# `my` declared below runs, so do_windows may read only globals declared ABOVE
# this line ($sock_path and the seven producer socket paths, $json_out,
# $timeout) and introduces no new file-scope state of its own.
if ($op eq 'windows') {
    exit do_windows();
}

# 10.E9 S1 (PHASE-BOOT-FSCK-RESILIENCE.md §3): the boot fsck auto-repair
# setting. `status` is a sessionless root-console read straight off sysd's
# socket (get_fsck_setting is tokenless: no capability, no session, works with
# authd down -- the surface an operator reaches for when a box just rebooted
# on its own). `enable` and `disable` are WRITES: set_fsck_setting is
# capability-minted and system:power:write-gated, so they go through authd
# with --session, exactly like `backup export`. They are deliberately NOT
# added to authd's pre-session console admit set (10.E8.6 F1/F3 -- that set
# is pinned at 30 by t/console_resolver_admit.t). `disable` additionally
# needs OGMACTL_CONFIRM=1 (the `backup apply` interlock) because it re-arms
# the console halt on the next unclean boot.
#
# An EARLY-EXIT verb of the status/windows class, subject to the same
# file-scope initializer hazard: it reads only globals declared above.
if ($op eq 'fsck-autorepair') {
    exit do_fsck_autorepair(@args);
}

# 10.E4 S3 (§7 D5): poll a background job to its terminal body by handle.
#
# authd has had a sessionless uid-0 `get_job` arm since 10.C4 S0, but nothing on
# the CLI could reach it: poll_job_terminal had exactly two callers, both
# internal. So a console `backup apply` -- which submits a Tier-A job and returns
# {job_id, status:"running"} -- gave the operator a handle and no way to use it,
# while get_restore_status reports a clean rollback and a success identically.
# This verb is the missing half, and it is deliberately generic (any job class,
# not just a restore): the same gap applies to any background op an operator
# starts from the console.
#
# An EARLY-EXIT verb like status/schema/support-bundle, subject to the same
# file-scope initializer hazard: it reads no `my` declared below.
if ($op eq 'job') {
    die "job requires a job id\n" unless @args >= 1;
    my $res = eval { poll_job_terminal($sock_path, $session, $args[0]) };
    if (!defined $res) {
        my $why = $@ || "poll failed\n";
        chomp $why;
        $res = { ok => JSON::PP::false, error => $why };
    }
    print JSON::PP->new->pretty->canonical->encode($res);
    exit($res->{ok} ? 0 : 1);
}

# 10.E8 S3 (Gate-0 §7 D13b): `help` / `help recover`. D13b DECLINED the
# ogmaprotectctl(8) man page to 10.E5 and ratified this pointer subcommand
# instead -- it names the on-box paths, it is NOT a second copy of the runbook
# (which would drift from it). An EARLY-EXIT verb like status/schema/
# support-bundle, subject to the same file-scope initializer hazard they
# document: it introduces no new file-scope state and reads none declared below.
if ($op eq 'help') {
    if (@args && $args[0] eq 'recover') {
        print <<'RECOVER';
OgmaProtect recovery pointers (root console).

The full procedure is ON THIS BOX:
  /etc/examples/ogmaprotect/README.recovery   lost admin, expired cert,
                                              mgmt lockout, support-access
                                              custody and engagement close
  /etc/examples/ogmaprotect/README.authdb     auth database + pledge notes
  /etc/examples/ogmaprotect/README.httpd      TLS/httpd bring-up
  /etc/examples/ogmaprotect/pf.boot.conf      the firstboot fail-closed ruleset

Lost or compromised admin (root console, no session needed):
  ogmaprotectctl user add rescue              # password on stdin, never argv
  ogmaprotectctl role grant rescue net-admin  # VERIFY the reply says ok:true
  ogmaprotectctl user set-password admin      # password on stdin

The RIGHT password is refused everywhere? Suspect an auth lockout, not a lost
password. Repeated wrong attempts lock the account for lockout_window_min
minutes (default 15) and the refusal says only "invalid credentials" -- on the
web AND from `ogmaprotectctl login`, by design on the network side:
  ogmaprotectctl user status admin            # locked? how many? until when?
  ogmaprotectctl user unlock admin            # clear it now
Read README.recovery section 2 -- it is section 4 that covers the FIREWALL
lockout, and `pfctl -d` will not help you sign in.

Withdraw support access:
  ogmaprotectctl role revoke <user> ogma-support

Management TLS certificate:
  ogmaprotectctl cert status                  # incl. any open txn_id
  ogmaprotectctl cert cancel                  # revert a bad rotation
  ogmaprotectctl cert import <crt> <key>      # install a cert you minted
  ogmaprotectctl cert confirm                 # keep the new cert

Wedged by an unconfirmed network change (root console, no session needed):
  ogmaprotectctl windows                      # EVERY open window, with its
                                              #   txn_id, interface and actor
  ogmaprotectctl status                       # open_windows names the domain
  ogmaprotectctl pf cancel                    # the FIREWALL window -- see
                                              #   section 4 if you are locked out
  ogmaprotectctl address cancel               # revert the pending change, or
  ogmaprotectctl carp cancel                  #   `... confirm` to keep it
  ogmaprotectctl v6|tunnel|wg cancel          # the S2 netd domains
  ogmaprotectctl routes|gateways|dns cancel   # rtd + dnsd (no interface)
  ogmaprotectctl ospf|bgp cancel              # routing: TWO pairs, never one
  ogmaprotectctl ipsec cancel                 # IPsec/IKEv2 -- read the caveat
`windows` is what you read BETWEEN `status` telling you a window is open and
typing one of the verbs above: it names every open window with the txn_id the
verb takes, the interface where the domain is scoped, who armed it, how long is
left, and -- the one thing the domain census can never show -- whether the
automatic revert FAILED. A domain it could not read says so; it never reports
an unreadable domain as clean.
Read README.recovery section 6 -- an armed window blocks every apply in its
domain (on netd, ALL netd applies) until resolved. There is at most ONE open
routing window: `ospf cancel` refuses a bgp window (and vice versa) rather
than resolve the wrong protocol, naming the verb to use instead.

RESTARTING A DAEMON RESOLVES ITS OPEN WINDOW. This is the confirm core's
startup recovery, not a quirk of one domain: it runs before the daemon's
socket reopens, so afterwards `<domain> cancel` honestly reports that no
window is open -- the same answer it gives when none ever existed, and the two
are indistinguishable here by design. What actually happened is in that
daemon's audit trail, as an `actor=system ... (recovery)` revert line naming
the txn. Measured on rtd 2026-08-24: a window armed at 23:42:46 was reverted
by recovery at 23:42:52, six seconds later, because the daemon was restarted
in between. If you are holding a window open deliberately, do not restart its
daemon.

IPsec differs in ONE way on top of that, and it is worth knowing before you
need it. Its prior configuration holds live keys, so it is deliberately never
written to disk -- it is held in memory by the running ipsecd. So where every
other domain's recovery puts the box back the way it was, ipsec's cannot: the
keys are gone, and the IPsec plane comes back DOWN rather than as it was.
Re-apply it from the saved configuration.

Locked out of the management plane: read README.recovery section 4 FIRST.
Both escapes cost you traffic -- `pfctl -d` also disables NAT and every
rdr/port-forward; loading pf.boot.conf additionally drops all transit, DHCP,
DNS and routing-protocol traffic. Save the ruleset before either.

Rebuilt the box and restoring its configuration? That is a DIFFERENT runbook:
  /etc/examples/ogmaprotect/README.restore   rebuild + configuration restore
  ogmaprotectctl backup stage --file <bundle>  # then validate, dry-run, apply
Read its section 0 before you wipe anything -- three things live outside every
bundle by design, and without them a restore is incomplete or impossible.

Is anything running?  ogmaprotectctl status
RECOVER
        exit 0;
    }
    usage(\*STDOUT);
    exit 0;
}

# 10.B6.x: verify the at-rest integrity tag on every daemon's live audit log.
# Works WITHOUT --session (a sessionless uid-0 console arm in authd, the
# get_restore_status precedent) because the case this exists for is "I suspect
# this box" - which includes suspecting the auth database. Returns counters
# only, never log content.
#
# NOTE the uid-0 arm runs BEFORE the session gate, so when ogmaprotectctl is
# invoked as root the session is not consulted even if --session is given (one
# op, two reach paths - the get_restore_status precedent). The session +
# authlog:export path is what the non-root web tier traverses.
if ($op eq 'audit-verify') {
    $req = { op => 'audit_verify', ($session ? (session_id => $session) : ()) };
} elsif ($op eq 'user' && @args >= 2 && $args[0] eq 'add') {
    # 10.E8 S3 (Gate-0 §7 D6): the password NO LONGER comes from argv, which is
    # world-visible in `ps auxww` on OpenBSD. stdin (the prompt_pass path, which
    # also reads a pipe when STDIN is not a tty) or OGMACTL_PASSWORD, on the
    # OGMACTL_PASSPHRASE precedent below. scripts/ogmaprotect-setup:416 already
    # pipes it this way and documents why.
    $req = { op => 'user_add', username => $args[1], password => pass_in() };
} elsif ($op eq 'user' && @args >= 2 && $args[0] eq 'set-password') {
    # 10.E8 S3 (§7 D6): the console password-ROTATION verb -- the direct form of
    # the lost/compromised-admin recovery that §0.8 could previously only reach
    # in two undocumented hops. Sessionless: authd answers user_password_reset on
    # its uid-0 arm before the session gate, so it works when the web tier is
    # down. It is a DIFFERENT op from the session `set_user_password`, which is
    # unchanged and still serves the web tier.
    $req = { op => 'user_password_reset', username => $args[1],
             new_password => pass_in() };
} elsif ($op eq 'user' && @args >= 2 && $args[0] eq 'status') {
    # 10.E8.5 (Gate-0 §7 D1): the console AUTH-LOCKOUT report. Sessionless --
    # authd answers user_status on its uid-0 arm before the session gate, which
    # is the point: the incident this exists for is "the right password is
    # refused everywhere", and the web tier is one of the surfaces refusing.
    #
    # NO session_id key, ever. A literal "session_id":null is RPOS_REJECT in
    # req_fields.inc, so authd would reject the whole request before reaching
    # the uid-0 arm (the trap recorded at the backup family below); omitting the
    # key is not a style choice.
    $req = { op => 'user_status', username => $args[1] };
} elsif ($op eq 'user' && @args >= 2 && $args[0] eq 'unlock') {
    # 10.E8.5 (§7 D1): clear that account's login_failures rows. authd does the
    # delete; the CLI never touches users.db -- which is exactly the
    # unsupported `sqlite3 DELETE` this verb exists to replace, and the reason
    # the ledger's remedy says "routed through authd, never a direct DB write".
    $req = { op => 'user_unlock', username => $args[1] };
} elsif ($op eq 'role' && @args >= 3 && $args[0] eq 'grant') {
    $req = { op => 'role_grant', username => $args[1], role => $args[2] };
} elsif ($op eq 'role' && @args >= 3 && $args[0] eq 'revoke') {
    # 10.E8 S3 (§7 D6a): withdraw a grant from the console. §0.8a recorded that
    # support access granted here could only be taken back through the web tier
    # -- exactly what may be broken during the incident. Since S3 an unknown user
    # OR an unknown role fails loudly instead of reporting ok:true having deleted
    # nothing (the D17 symmetry fix in ogma_auth_revoke_role).
    $req = { op => 'role_revoke', username => $args[1], role => $args[2] };
} elsif ($op eq 'cert' && @args >= 1) {
    $req = cert_request(\@args);
} elsif ($op eq 'login' && @args >= 2) {
    $req = { op => 'login', username => $args[0], password => $args[1] };
} elsif ($op eq 'backup' && @args >= 1) {
    my $sub = $args[0];
    # 10.E4 S3 (§5 `P-import`, §7 D8): the session requirement is now PER
    # SUB-VERB, not one blanket die over all four. authd grew sessionless uid-0
    # arms for the three IMPORT ops, because a rebuilt appliance has no accounts
    # and may have no web tier -- the scenario a restore exists for. `export` is
    # deliberately NOT among them: the ratified 10.E4/10.E8 split gives 10.E4
    # config/backup RESTORE, and egress from the root socket would need an
    # 10.E8-side amendment. `stage` needs no daemon at all.
    #
    # The dual-path shape below is `pf confirm|cancel`'s: with --session build
    # the session request unchanged; without it, send `actor => 'console'` and
    # OMIT session_id entirely. Omitting is not a style choice -- a literal
    # "session_id":null is RPOS_REJECT in req_fields.inc, so authd would reject
    # the whole request before ever reaching its uid-0 arm.
    # 10.E4 S4 (`K-verb`, §7 D14): two client-side refusals, BOTH sited above the
    # `stage` early-exit because GetOptions is global -- the flag parses on any
    # sub-verb, and `stage` and `export` would silently ignore it. That silence is
    # the trap: an operator who typed it once on `stage`, saw ok:true, and then ran
    # `validate` without it would meet "bundle signature invalid" with nothing
    # connecting the two, which is the tar-surgery on-ramp M8 exists to close.
    #
    # ⚠ These are MESSAGES, not the control. authd's prep chain refuses a session
    # request carrying accept_foreign (denied_foreign_waiver_not_console) whether
    # or not the ctl checked, and t/console_sentinel_closure.t pins that. Deleting
    # either die must NOT make the operation possible.
    die "backup $sub does not take --accept-foreign\n"
        if $accept_foreign && ($sub eq 'stage' || $sub eq 'export'
            || $sub eq 'anchor');
    die "backup --accept-foreign is a root-console option; it cannot be "
        . "combined with --session\n" if $accept_foreign && $session;
    if ($sub eq 'stage') {
        exit do_backup_stage();
    }
    # 10.B5.1: LOCAL, read-only anchor identity — no daemon op, no session, no
    # write path (M6 / §9 P6). Prints the deployment backup anchor's keynum, its
    # PUBLIC-key line and the .pub sha256, so an operator can confirm which anchor
    # this box holds and hand the verify-only public half to a monitor/fleet-mate.
    # The private half is never read or shown.
    if ($sub eq 'anchor') {
        exit do_backup_anchor();
    }
    die "backup export requires --session\n" if $sub eq 'export' && !$session;
    # 10.B5: an optional AEAD passphrase, taken from the environment (NOT argv,
    # which is world-visible in ps). On export it encrypts the signed bundle at
    # rest; on validate/dry-run/apply it decrypts an enveloped bundle. Empty =>
    # the plaintext path (unchanged). Symmetric with the web export/import forms.
    my @enc = (defined $ENV{OGMACTL_PASSPHRASE} && $ENV{OGMACTL_PASSPHRASE} ne '')
        ? (backup_passphrase => $ENV{OGMACTL_PASSPHRASE}) : ();
    # The identity half of every import request: a session, or the console
    # actor. @enc must ride BOTH forms or an encrypted bundle silently becomes
    # `denied_passphrase_required` on the console path.
    my @who = $session ? (session_id => $session) : (actor => 'console');
    # 10.E4 S4: the waiver rides all three import ops (M8 constraint 4 -- without
    # it on validate/dry-run the operator cannot pre-flight a foreign bundle at
    # all and meets the §0.11 abort class mid-restore). Spliced, not always-sent,
    # on the @enc/@who precedent: absent means absent, and the normal path carries
    # no trace of a waiver it did not ask for.
    my @fgn = $accept_foreign ? (accept_foreign => JSON::PP::true) : ();
    if ($sub eq 'export') {
        $req = { op => 'export_backup', session_id => $session, @enc };
    } elsif ($sub eq 'validate') {
        die "backup validate requires --path\n" unless $path;
        $req = { op => 'validate_backup', @who, backup_path => $path, @enc, @fgn };
    } elsif ($sub eq 'dry-run') {
        die "backup dry-run requires --path\n" unless $path;
        $req = { op => 'dry_run_backup', @who, backup_path => $path, @enc, @fgn };
    } elsif ($sub eq 'apply') {
        die "backup apply requires --path\n" unless $path;
        # 10.E4 S3: §7 D14(2) keeps this interlock on the sessionless arm too --
        # `apply` is the destructive verb whether or not a session is involved,
        # and over-hardening the supported path is what drives an operator back
        # to hand-editing an archive mid-incident.
        die "backup apply requires OGMACTL_CONFIRM=1\n" unless $ENV{OGMACTL_CONFIRM};
        die "backup apply requires --staged-sha256\n"
            unless $staged_sha && $staged_sha =~ /\A[0-9a-f]{64}\z/;
        $req = {
            op              => 'apply_backup',
            @who,
            backup_path     => $path,
            staged_sha256   => $staged_sha,
            confirm         => JSON::PP::true,
            ack_destructive => ($ENV{OGMACTL_ACK_DESTRUCTIVE} ? JSON::PP::true : JSON::PP::false),
            @enc,
            @fgn,
        };
    } else {
        die "unknown backup subcommand: $sub\n";
    }
} elsif ($op eq 'pf' && @args >= 1) {
    my ($sub, $txn) = @args;
    if ($sub eq 'confirm' || $sub eq 'cancel') {
        # 10.E8.6 S2 (§7 D8 `P-optional`, §3 M5): the txn_id is OPTIONAL, and
        # this is the row's ONLY behaviour change outside the report (F6).
        #
        # It was MANDATORY from Phase 4 until here, and nothing on the box
        # handed a console operator one. `get_pf` has folded a full
        # `"pending":{txn_id,deadline,remaining,actor,revert_failed}` object
        # since Phase 4 -- INLINE, which is why every census taken by grepping
        # `*_pending_fragment` recorded eleven fragments instead of twelve --
        # and it was called from NOWHERE in this file. So `pf confirm|cancel`
        # was a shipped DEAD VERB, in the one domain whose window gates the
        # firewall, and the refusal an operator actually met was the bare
        # string "pending-confirmation". S1 made the txn READABLE
        # (`ogmaprotectctl windows`); this makes the verb take it.
        #
        # ADDITIVE in exactly the sense F6 fences: a txn that IS given is
        # never ignored (it is still validated and still sent verbatim), the
        # two op strings are unchanged, the direct-pfd sessionless path is
        # unchanged, the session path is unchanged, and 10.E8.2's anti-lockout
        # predicate is untouched. The ONE thing that changes is that omitting
        # the argument now resolves instead of dying.
        $txn = pf_pending_txn() unless defined $txn && $txn ne '';
        die "pf $sub: no pf window is open (and none was given)\n"
            unless defined $txn && $txn ne '';
        die "invalid txn_id\n" unless $txn =~ /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/;
        my $opname = $sub eq 'confirm' ? 'confirm_pf' : 'cancel_pf';
        if ($session) {
            # Normal management path: proves the new ruleset did not cut
            # off browser/CGI/authd access.
            $req = { op => $opname, session_id => $session, txn_id => $txn };
        } else {
            # Console recovery over the root pfd socket — for when the
            # web path is what broke (peer ACL: root only).
            $direct_pfd = 1;
            $req = { op => $opname, txn_id => $txn, actor => 'console' };
        }
    } else {
        die "unknown pf subcommand: $sub\n";
    }
} elsif ($op eq 'address' && @args >= 1) {
    # 10.E8.1 S1 (Gate-0 §3 M2, §7 D8/D9/D10): the console window-resolver
    # verb family, first two domains. Cert-shaped -- sessionless, answered on
    # authd's uid-0 fall-through arm before the session gate, because the
    # scenario these exist for is a pending change that destroyed the
    # management session (the window wedge). Verb names are the open_windows
    # labels the operator is reading during the incident (§7 D9).
    $req = window_request('address', \@args);
} elsif ($op eq 'carp' && @args >= 1) {
    $req = window_request('carp', \@args);
} elsif ($op eq 'v6' && @args >= 1) {
    # 10.E8.1 S2: the three netd default-arming domains join (S3 adds
    # rtd/dnsd/routed; S4 ipsec). Same shape throughout.
    $req = window_request('v6', \@args);
} elsif ($op eq 'tunnel' && @args >= 1) {
    $req = window_request('tunnel', \@args);
} elsif ($op eq 'wg' && @args >= 1) {
    $req = window_request('wg', \@args);
} elsif ($op eq 'routes' && @args >= 1) {
    # 10.E8.1 S3: the DOMAIN-SCOPED grammar class (§3 M2) -- rtd, dnsd and
    # routed. No interface, in the grammar or on the wire: these five
    # terminals key on the txn alone, and the confirm core admits at most one
    # marker per domain, so "the open window" is a total function of the
    # domain and there is nothing to enumerate. Verb names are the
    # open_windows labels (§7 D9): `routes`, not the op stem `route`.
    $req = window_request('routes', \@args);
} elsif ($op eq 'gateways' && @args >= 1) {
    $req = window_request('gateways', \@args);
} elsif ($op eq 'dns' && @args >= 1) {
    $req = window_request('dns', \@args);
} elsif ($op eq 'ospf' && @args >= 1) {
    # §7 D7: the routing domain is TWO verb pairs. The console never presents
    # `routing` as one resolvable thing; the resolver filters on the window
    # fragment's svc tag and the daemon enforces the same binding again.
    $req = window_request('ospf', \@args);
} elsif ($op eq 'bgp' && @args >= 1) {
    $req = window_request('bgp', \@args);
} elsif ($op eq 'ipsec' && @args >= 1) {
    # 10.E8.1 S4: the LAST domain. Domain-scoped like the S3 five -- ipsecd's
    # terminals key on the txn alone, and ipsec has no per-interface scoping.
    # The restart-survival bound is documented at `help recover` and in the man
    # page, not worked around here: after an ipsecd restart this verb honestly
    # reports no window, because startup recovery already resolved it.
    $req = window_request('ipsec', \@args);
} elsif ($op eq 'update' && @args >= 1) {
    # 10.E10 S1+S2 (contract §3.7 M-console, §7 D-11): the two-step update
    # from the root console -- `update stage` downloads and verifies the
    # artifact the held update verdict names (the package on Lite, the image
    # onto the inactive root on the appliance; a stage never fetches a
    # manifest: run the check first), `update apply` installs it -- pkg_add on
    # Lite, the boot.conf flip on the image -- and REBOOTS, `update discard`
    # throws the staged bytes away (image: and zeroes the first MiB of the
    # staged root). The verbs and their copy are edition-neutral by design:
    # sysd owns the branch. Sessionless: authd answers
    # all three on its uid-0 arm before the session gate (the admit set grew
    # 30 -> 33 by D-11), so they work when the web tier is down, the
    # certificate has expired or the session is lost -- the Saturday-morning
    # outage window the mandate describes. NO session_id key, ever (a literal
    # null is RPOS_REJECT; the backup family records the trap).
    #
    # `apply` is the destructive verb: it needs OGMACTL_CONFIRM=1 (the `backup
    # apply` interlock) and sets BOTH ack booleans itself -- sysd floors them
    # again behind authd (the halt/poweroff two-ack shape: the verb installs
    # and reboots). Without the interlock it dies BEFORE any request (§9 P7).
    # `stage` is not a long-poll: it returns the ack and the operator watches
    # `ogmaprotectctl status` (the Staged: line). Three literal `op => '...'`
    # sites, never a ternary -- the t/op_contract.t constraint.
    my $sub = $args[0];
    if ($sub eq 'stage') {
        $req = { op => 'stage_update', actor => 'console' };
    } elsif ($sub eq 'discard') {
        $req = { op => 'discard_update', actor => 'console' };
    } elsif ($sub eq 'apply') {
        die "update apply installs the staged update and REBOOTS this box; "
          . "it requires OGMACTL_CONFIRM=1\n" unless $ENV{OGMACTL_CONFIRM};
        $req = {
            op                   => 'apply_update',
            actor                => 'console',
            confirm_system_power => JSON::PP::true,
            confirm_unrecoverable => JSON::PP::true,
        };
    } else {
        die "unknown update subcommand: $sub (stage|apply|discard)\n";
    }
} else {
    die "unknown command\n";
}

my $peer = $direct_pfd ? $pfd_path : ($direct_netd ? $netd_path : $sock_path);
my $r = daemon_call($peer, $req);

# 10.C4 S7: export/validate/dry-run submit an ARCHIVE background job and
# reply {job_id,status:"running"} in <1 s; poll get_job to the terminal
# spool body so the CLI keeps its synchronous contract. The apply
# subcommand deliberately stays one-shot (its raw {ok,job_id} print is the
# recorded S1 behavior). A non-zero exit_code is a failure even when the
# spool is empty (a FAILED worker may write no body).
if ($op eq 'backup' && $args[0] =~ /\A(?:export|validate|dry-run)\z/
    && $r->{ok} && $r->{job_id}) {
    # 10.E4 S3: eval-wrapped for the same reason the sessionless apply below
    # is. poll_job_terminal DIES on its 420 s wall and on a get_job transport
    # failure; uncaught that is exit 255 with no job id printed. `validate` is
    # the FIRST verb a console operator runs in a recovery, and this slice is
    # what makes it reachable with no session -- losing the handle there is
    # the same defect, one verb earlier.
    my $id = $r->{job_id};
    my $t = eval { poll_job_terminal($peer, $session, $id) };
    if (!defined $t) {
        my $why = $@ || "poll failed\n";
        chomp $why;
        $t = {
            ok     => JSON::PP::false,
            job_id => $id,
            error  => $why,
            hint   => "still running; re-attach with: ogmaprotectctl job $id",
        };
    }
    $r = $t;
}

# 10.E4 S3: the SESSIONLESS apply polls too, and only the sessionless one.
#
# The session apply's raw one-shot {ok,job_id} print is pinned S1 behaviour and
# is untouched above -- a session operator has the web tier's poller and the
# /backup banner. A CONSOLE operator has neither: before this, `backup apply`
# from the root console returned a job id and then nothing could ever be done
# with it, because the ctl exposed no verb taking one and get_restore_status
# renders a clean rollback and a success identically (both idle). That is the
# same "refused and cannot tell why" failure §7 D5 refuses to accept, one layer
# out, so the console path folds into the same terminal poll the other verbs
# use. If the poll wall (420 s) expires before a long restore commits, the id
# is printed with the `job` verb to re-attach -- inside the 300 s grace.
if ($op eq 'backup' && $args[0] eq 'apply' && !$session
    && $r->{ok} && $r->{job_id}) {
    my $id = $r->{job_id};
    # eval-wrapped: poll_job_terminal DIES on its 420 s wall and on a get_job
    # transport failure. An uncaught die here would exit 255 and print no job
    # id at all -- losing the only handle the operator has, and inside the
    # 300 s grace window it is still re-attachable. Report it instead.
    my $t = eval { poll_job_terminal($peer, undef, $id) };
    if (!defined $t) {
        my $why = $@ || "poll failed\n";
        chomp $why;
        $t = {
            ok     => JSON::PP::false,
            job_id => $id,
            error  => $why,
            hint   => "the restore may still be running; re-attach with: " .
                      "ogmaprotectctl job $id",
        };
    }
    $r = $t;
}

if ($op eq 'backup' && $args[0] eq 'export' && $r->{ok}) {
    my $src = $r->{backup_path} // '';
    die "export missing backup_path\n" unless $src;
    my $dest = $out // 'ogmaprotect-backup.ogma';
    open my $in, '<', $src or die "read $src: $!\n";
    binmode $in;
    open my $fh, '>', $dest or die "write $dest: $!\n";
    binmode $fh;
    while (read($in, my $buf, 65536)) {
        print $fh $buf or die "write $dest: $!\n";
    }
    close $in;
    close $fh;
    unlink $src;
    print JSON::PP->new->pretty->encode({ ok => JSON::PP::true, path => $dest });
    exit 0;
}

print JSON::PP->new->pretty->encode($r);

# 10.E8 S3: the BREAK-GLASS verbs carry their verdict in the EXIT STATUS.
#
# The legacy tail below prints the reply and falls off the end, so this tool has
# always exited 0 regardless of `ok` -- scripts/ogmaprotect-setup:379-381 records
# it verbatim ("exits 0 regardless of ok, so we parse the body, not $?") and
# t/ogmaprotect_setup.t pins that workaround. That is survivable for the verbs
# whose callers already parse the body; it is NOT survivable for a verb an
# operator scripts under pressure. Left alone,
# `ogmaprotectctl cert import a.pem b.pem && echo installed` prints "installed"
# on a refusal, and a `cert confirm` that silently failed auto-reverts the
# certificate 300s later with the operator believing it was kept.
#
# Scoped to the S3 verbs only, on the audit-verify precedent below: every
# pre-existing verb's exit contract is unchanged, so no shipped caller moves.
if (($op eq 'cert')
    || ($op eq 'role' && @args && $args[0] eq 'revoke')
    || ($op eq 'user' && @args && $args[0] eq 'set-password')
    # 10.E8.5: the auth-lockout pair joins, same argument. `user unlock admin &&
    # systemctl-style follow-on` printing success after a refusal would leave
    # the operator believing the account is usable while it is still locked;
    # `user status` carries its verdict in the BODY (`locked`), so its exit
    # status reports only whether the QUERY succeeded -- which is the
    # distinction that matters when the auth database is the thing that is
    # broken. New verbs, so no shipped caller's exit contract moves.
    || ($op eq 'user' && @args && ($args[0] eq 'unlock' || $args[0] eq 'status'))
    # 10.E4 S3: the SESSIONLESS backup verbs join, and only those. The same
    # argument applies verbatim and more sharply -- a console restore is the
    # most consequential thing this tool does, `backup apply … && echo restored`
    # would print "restored" on a rolled-back restore, and the console operator
    # has no web banner to contradict it. Scoped to `!$session` so every
    # pre-existing session caller's exit contract is untouched, including the
    # `ogmaprotect-setup` workaround t/ogmaprotect_setup.t pins. These are
    # terminal here: validate/dry-run/apply all now poll to a result above.
    || ($op eq 'backup' && !$session && @args
        && $args[0] =~ /\A(?:validate|dry-run|apply)\z/)
    # 10.E8.1 S1: the window-resolver verbs join, same argument -- a
    # `carp cancel && echo reverted` printing "reverted" while the window
    # stayed armed would leave the operator wedged AND misinformed. New
    # verbs, so no shipped caller's exit contract moves.
    || ($op eq 'address') || ($op eq 'carp')
    # 10.E8.1 S2: the three netd siblings join, same argument.
    || ($op eq 'v6') || ($op eq 'tunnel') || ($op eq 'wg')
    # 10.E8.1 S3: the five domain-scoped siblings join, same argument --
    # `routes cancel && echo reverted` printing "reverted" while the window
    # stayed armed leaves the operator wedged AND misinformed.
    || ($op eq 'routes') || ($op eq 'gateways') || ($op eq 'dns')
    || ($op eq 'ospf') || ($op eq 'bgp')
    # 10.E8.1 S4: ipsec joins, same argument.
    || ($op eq 'ipsec')
    # 10.E10 S1 (§3.7): the update verbs join -- `update apply && echo done`
    # printing "done" after a refused apply (stale stage, unverified install)
    # would leave the operator believing a reboot is coming. New verbs, so no
    # shipped caller's exit contract moves.
    || ($op eq 'update')) {
    exit($r->{ok} ? 0 : 1);
}

# 10.B6.x: audit-verify is a monitoring/forensic verb, so its EXIT STATUS must
# carry the verdict — a cron entry that only ever sees 0 is worse than no check
# at all. 0 = every daemon clean, 1 = at least one non-clean verdict (bad_mac /
# seq_gap / downgrade / key_epoch / unkeyed / unreadable / no_key, or a ledger
# finding: truncated / gen_replay / downgrade), 2 = the op itself could not run.
# Mirrors `ogmaprotectctl status`.
#
# 10.B6.x S3: ledger_write_failed also exits 1. It is not tamper — a full or
# read-only /var/db — but it means the generation anchor is NOT being maintained,
# so cross-generation rollback and truncation are going undetected. A cron
# consumer that could not see that would be told "all clear" by a check that had
# silently stopped checking. ledger_reset deliberately does NOT exit 1: it is
# also the legitimate first-run / re-image / `deploy.py clean` path, and it is
# reported in the body and audited into authd's own trail.
if ($op eq 'audit-verify') {
    exit 2 unless $r->{ok};
    my $av = $r->{audit_verify};
    exit 2 unless ref $av eq 'HASH' && defined $av->{clean};
    exit 1 if $av->{ledger_write_failed};
    exit($av->{clean} ? 0 : 1);
}

# 10.C4 S7: drain get_job to the terminal body. Returns the op's former
# synchronous reply body (the spool JSON) on success, or {ok:false,error}.
# 10.E3 S3: the per-CALL alarm bound for the support-bundle verb's two daemon
# calls. Chosen against the SERIAL PARENT, not the wire: support_bundle_download
# publishes a ≤4 MiB copy inside authd's accept loop and every apply-family
# submit contends for it, so a value in the low seconds would manufacture false
# timeouts on a busy box. It must also stay well below poll_job_terminal's own
# 420 s wall, which remains the authority on total elapsed time.
use constant OGMA_CTL_CALL_TIMEOUT => 45;

# 10.E3 S3: $call_to is the PER-CALL alarm bound, passed through to
# daemon_call. Optional and undef for every pre-existing caller, so the backup
# family's behaviour is unchanged -- deliberately, because that call site is not
# eval-wrapped and a new die there would become an uncaught exit 255 instead of
# its current JSON refusal. It must stay well BELOW the loop's own 420 s wall so
# the wall remains the authority on total elapsed time.
sub poll_job_terminal {
    my ($peer, $sess, $job, $call_to) = @_;
    die "invalid job_id\n"
        unless $job =~ /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/;
    my $off   = 0;
    my $chunk = '';
    my $t0    = time;
    while (1) {
        # 10.E3 S3: build the request WITHOUT session_id when there is no
        # session. Previously this key was always present, so a sessionless
        # caller sent `"session_id":null` -- and session_id is RPOS_REJECT in
        # req_fields.inc while token_to_string() fails any non-string token,
        # so authd rejected the WHOLE request before it could ever reach the
        # uid-0 get_job arm. The sessionless job path was therefore dead on
        # arrival; nothing caught it because every caller until now was the
        # backup family, which die()s without --session.
        my $jr = {
            op          => 'get_job',
            txn_id      => $job,
            diag_offset => $off,
        };
        $jr->{session_id} = $sess if defined $sess && $sess ne '';
        my $p = daemon_call($peer, $jr, $call_to);
        die "get_job failed: " . ($p->{error} // 'unknown') . "\n"
            unless $p->{ok};
        $chunk .= $p->{chunk} // '';
        $off = $p->{offset} // $off;
        if ($p->{done}) {
            my $ec = defined $p->{exit_code} ? $p->{exit_code} : -1;
            my $body;
            $body = eval { JSON::PP->new->decode($chunk) }
                if $chunk =~ /\S/;
            if ($ec != 0) {
                my $err = (ref $body eq 'HASH' && $body->{error})
                    ? $body->{error}
                    : 'job ' . ($p->{status} // 'failed') . " (exit $ec)";
                return { ok => JSON::PP::false, error => $err };
            }
            die "job done but spool body unparsable\n"
                unless ref $body eq 'HASH';
            return $body;
        }
        # Bound a wedged poll loop ABOVE the longest class deadline that can
        # reach this helper: ARCHIVE is 180 s but the 10.E3 S3 SUPPORT class is
        # 320 s, so a 300 s cap would have abandoned a healthy bundle just
        # before its watchdog fired and reported a timeout that was ours.
        die "job poll timed out\n" if time - $t0 > 420;
        select(undef, undef, undef, 1.0);
    }
}

# 10.E3 S3: an OPTIONAL alarm bound.
#
# Without $to this is byte-for-byte the historical behaviour, so no existing
# caller changes. With it, the connect + the single blocking <$s> read run under
# alarm($to) -- the _probe idiom nine lines below, which is what makes `status`
# able to report a wedged daemon at all.
#
# It matters here because `poll_job_terminal`'s 420 s wall-clock cap is checked
# only BETWEEN iterations: a daemon that accepts the connection and never
# answers leaves this read blocked forever and that cap can never fire. The
# support-bundle verb is the one that noticed, but the hazard is the helper's.
#
# Three details, each of which is a way to get this wrong:
#   - the handler is `local`ised INSIDE the eval, because a bare alarm with
#     SIGALRM at its default disposition TERMINATES perl (exit 142, no message),
#     which would silently break the verb's pinned 0/1/2 exit contract;
#   - alarm(0) runs OUTSIDE the eval, so a die on any path (including the
#     connect failure) cannot leave a pending alarm to fire during the caller's
#     own error handling;
#   - an undef read is raised explicitly, because chomp/decode on undef dies
#     with a message that names neither the socket nor the timeout.
sub daemon_call {
    my ($path, $req, $to) = @_;
    my ($resp, $err);

    eval {
        local $SIG{ALRM} = sub { die "OGMATIMEOUT\n" };
        alarm($to) if defined $to && $to > 0;
        my $s = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $path)
            or die "connect $path: $!\n";
        my $j = JSON::PP->new->encode($req);
        print $s $j, "\n";
        my $line = <$s>;
        die "no reply from $path\n" unless defined $line;
        chomp $line;
        $resp = JSON::PP->new->decode($line);
        # DISARM INSIDE the eval, while the handler is still localised. The
        # outer alarm(0) below cannot cover the success path: `local
        # $SIG{ALRM}` is unwound when this block exits, so an expiry landing
        # between the block ending and the outer alarm(0) executing would meet
        # SIGALRM's DEFAULT disposition and terminate perl (exit 142, no
        # message) -- exactly the outcome the handler exists to prevent, and
        # reachable on any call that completes at about $to seconds.
        alarm(0);
        1;
    } or do { $err = $@ || "unknown error\n" };
    # The die path: the handler is already unwound here, so this is a
    # belt-and-braces clear of an alarm that may still be pending.
    alarm(0);

    if (defined $err) {
        die "timed out after ${to}s waiting for $path\n"
            if $err =~ /^OGMATIMEOUT/;
        die $err;
    }
    return $resp;
}

sub prompt_pass {
    print "Password: ";
    system 'stty -echo' if -t STDIN;
    my $p = <STDIN>;
    chomp $p;
    system 'stty echo' if -t STDIN;
    print "\n";
    die "password required\n" unless $p;
    return $p;
}

# 10.E8 S3 (Gate-0 §7 D6, "stdin/env only"): the single source of a password for
# every credential verb. NEVER argv -- `ps auxww` is world-readable on OpenBSD.
# OGMACTL_PASSWORD mirrors OGMACTL_PASSPHRASE (the 10.B5 precedent); otherwise
# prompt_pass, which reads a pipe unchanged when STDIN is not a tty (that is how
# scripts/ogmaprotect-setup:416 already feeds `user add`).
sub pass_in {
    return $ENV{OGMACTL_PASSWORD}
        if defined $ENV{OGMACTL_PASSWORD} && $ENV{OGMACTL_PASSWORD} ne '';
    return prompt_pass();
}

# 10.E8 S3 (§7 D12/D12a/D12b): the mgmt-TLS break-glass verbs.
#
# All five ops answer on authd's uid-0 arm before the session gate, so they work
# with no session -- which is the point: an expired or broken management
# certificate is precisely what denies you one.
#
# `confirm`/`cancel` take an OPTIONAL txn_id. That is not a convenience: nothing
# else on the box hands a console operator the open window's id (the refusal is
# the bare string "pending-confirmation"; the arm-time audit line carries no id),
# so a mandatory argument would make the resolvers undrivable in the one scenario
# they exist for. When omitted we resolve it from `get_cert`'s pending fragment,
# which is the only surface that emits it.
sub cert_request {
    my ($args) = @_;
    my $sub = $args->[0];

    if ($sub eq 'status') {
        return { op => 'get_cert' };
    }
    if ($sub eq 'self-signed') {
        die "cert self-signed requires --cn <dns-name>\n"
            unless defined $cert_cn && $cert_cn ne '';
        # The generator emits DNS: SANs only. Since S3 sysd REFUSES an IP
        # literal outright rather than minting a cert browsers silently reject;
        # say so here too, before the round trip.
        die "cert self-signed: --cn must be a DNS name, not an IP address.\n"
          . "Mint the cert with openssl (see "
          . "/etc/examples/ogmaprotect/README.recovery) and use `cert import`.\n"
            if $cert_cn =~ /\A[0-9.]+\z/ || $cert_cn =~ /:/;
        my @san;
        if (defined $cert_san && $cert_san ne '') {
            # cert_san is a JSON ARRAY on the wire -- ogma_parse.c rejects the
            # whole request if it is anything else, and the operator would see
            # an opaque parse refusal with no clue why.
            @san = grep { $_ ne '' } split /\s*,\s*/, $cert_san;
            die "cert self-signed: at most 16 SAN entries\n" if @san > 16;
        }
        die "cert self-signed: --days must be 1..3650\n"
            if defined $cert_days && ($cert_days < 1 || $cert_days > 3650);
        return {
            op => 'generate_selfsigned', cert_cn => $cert_cn,
            (@san ? (cert_san => \@san) : ()),
            (defined $cert_days ? (cert_days => $cert_days) : ()),
            (defined $confirm_timeout ? (confirm_timeout => $confirm_timeout) : ()),
        };
    }
    if ($sub eq 'import') {
        my ($crt, $key) = ($args->[1], $args->[2]);
        die "cert import requires <cert-path> <key-path>\n"
            unless defined $crt && defined $key;
        # Bound the two reads locally: over the wire an oversize PEM is an
        # opaque parse refusal (OGMA_MAX_CERT_PEM / OGMA_MAX_CERT_KEY).
        my $pem = slurp_pem($crt, 24576, 'certificate');
        my $kv  = slurp_pem($key, 8192, 'private key');
        return {
            op => 'import_cert', cert_pem => $pem, cert_key_value => $kv,
            (defined $confirm_timeout ? (confirm_timeout => $confirm_timeout) : ()),
        };
    }
    if ($sub eq 'confirm' || $sub eq 'cancel') {
        my $txn = $args->[1];
        $txn = cert_pending_txn() unless defined $txn && $txn ne '';
        die "cert $sub: no certificate window is open "
          . "(and none was given)\n" unless defined $txn && $txn ne '';
        die "invalid txn_id\n"
            unless $txn =~ /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/;
        # Two literal `op => '...'` sites rather than one ternary: t/op_contract.t
        # scans this file for exactly that shape and pins every op string to a
        # registry row. A ternary inside the value is invisible to it, so the
        # two resolver ops would ship unpinned and a typo would reach the box.
        return { op => 'confirm_cert', txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_cert',  txn_id => $txn };
    }
    die "unknown cert subcommand: $sub\n";
}

# Read the open cert window's txn_id from get_cert's pending fragment -- the ONE
# producer of it anywhere on the wire (ogma_sysd_cert_pending_fragment, served by
# get_cert alone). Returns undef when no window is open.
#
# 10.E8.6 S1 (§3 M2, §7 D4): FOLDED ONTO THE PROBE TABLE. This sub used to
# hand-roll its own socket, op and extractor -- a thirteenth clone of a table
# that already had twelve rows -- and the target moved from authd to SYSD-DIRECT
# with it, so every window probe in this file now has one shape and one place to
# change. The terminal confirm_cert/cancel_cert are untouched: they still ride
# authd's uid-0 arm, and authd's pre-session admit set does not move (F3).
sub cert_pending_txn {
    my $spec = window_probe_spec('cert');
    # BOUNDED. The chain's shared tail uses an unbounded daemon_call, but this
    # probe runs BEFORE it on a break-glass path: a daemon that accepts the
    # connection and never answers would otherwise hang `cert cancel` forever,
    # which is the failure daemon_call's optional deadline exists for.
    my $r = daemon_call($spec->{sock}, { op => $spec->{op} },
                        $timeout || OGMA_CTL_CALL_TIMEOUT);
    return undef unless $r && $r->{ok};
    my $p = $spec->{pending}->($r);
    return undef unless $p && ref $p eq 'HASH';
    return $p->{txn_id};
}

# Read the open pf window's txn_id from get_pf's pending fragment.
#
# 10.E8.6 S2 (§3 M5): the cert_pending_txn model, one domain over, and folded
# onto the SAME probe table for the same reason S1 folded cert onto it -- the
# socket, the op and the extractor are the table's business, not this sub's.
# Returns undef when no window is open.
#
# The read goes DIRECT to pfd's root socket (`P-direct`): get_pf is
# OGMA_PERM_PF_READ, which is tokenless by the perm registry, and pfd's peer
# ACL is uid == 0. Nothing joins authd's pre-session admit set for this (F3) --
# which matters, because `get_pf` is NOT on that set and never has been.
sub pf_pending_txn {
    my $spec = window_probe_spec('pf');
    # BOUNDED, for cert_pending_txn's reason: the chain's shared tail uses an
    # unbounded daemon_call, but this probe runs BEFORE it on a break-glass
    # path, and a pfd that accepts the connection and never answers would
    # otherwise hang `pf cancel` forever -- during a firewall lockout, which
    # is the one moment this verb exists for.
    my $r = daemon_call($spec->{sock}, { op => $spec->{op} },
                        $timeout || OGMA_CTL_CALL_TIMEOUT);
    return undef unless $r && $r->{ok};
    my $p = $spec->{pending}->($r);
    return undef unless $p && ref $p eq 'HASH';
    return $p->{txn_id};
}

# ---- 10.E8.1 S1: console commit-confirm window resolvers -------------------
#
# One verb pair per confirm domain, cert-shaped (§3 M2): sessionless requests
# ride authd's uid-0 fall-through arm, which sanitizes with actor=console and
# proxies to the owning daemon minting the cap token in flight -- cap.c's
# tokenless-exempt allowlist is untouched (§4 F1). Two grammar classes (§7 D8):
# INTERFACE-SCOPED (address, carp, and -- since S2 -- v6/tunnel/wg) because the netd
# terminals hard-require a non-empty interface that matches the marker's
# (address_pending.c "REQUIRE a non-empty interface here"), and DOMAIN-SCOPED
# (since S3: routes/gateways/dns/ospf/bgp -- rows with iface_scoped 0 and no
# interface key on the wire; S4 adds ipsec). The txn is OPTIONAL (§7 D2 `T-optional`, the
# cert_pending_txn precedent): nothing but a get_* window fragment hands a
# console operator one, so a mandatory argument would make the resolver
# undrivable in the one scenario it exists for.
#
# The PROBES go DIRECT to netd's root socket (§7 D10 `P-direct`): reads are
# tokenless by the perm registry, the peer ACL is uid==0, and this is the
# shipped drift-walk pattern -- no producer op joins authd's pre-session
# surface. Each probe is alarm-bounded (the cert_pending_txn rationale: an
# accepted-but-unanswered connect must not hang a break-glass verb forever).
#
# PROBE CHOICE per domain: address uses its per-interface producer
# (get_interface; the fragment is keyed on the marker's user_iface) and
# enumerates via get_net when no interface was named. carp uses the match-any
# get_ha_status fold for BOTH forms -- its fragment carries "interface", and
# get_carp cannot answer for a windowed CREATE (the canonical entry is
# deferred until confirm, so "no such carp interface" is that probe's answer
# mid-window, which is precisely the incident state).
#
# 10.E8.1 S2: v6 rides get_ipv6 (its fragment is NESTED inside the "ipv6"
# object on a direct netd read -- the rev2 R12 siting -- so the extractor
# differs); tunnel rides get_tunnel (top-level "pending", disk-find-backed as
# of S2 so a revert_failed window reports); wg rides get_wireguard, which as
# of S2 answers mid-window with the pending-create body (a windowed wg apply
# is a CREATE whose canonical entry is deferred, so before S2 the canonical
# miss refused and no console surface carried the txn). wg ENUMERATION has a
# second candidate source: a windowed wgN is in neither canonical nor
# get_net, so the no-arg form also lists LIVE interfaces via list_interfaces
# (the same tokenless root-socket read class) and probes the ^wg\d+$ ones --
# the daemon stays transport AND authority for every candidate.
#
# 10.E8.1 S3: the five DOMAIN-SCOPED domains probe their OWN daemon's root
# socket -- routes/gateways on rtd, dns on dnsd, ospf/bgp on routed -- all the
# same tokenless uid-0 read class (§7 D10 `P-direct`; every one of the five
# READ perms is in cap.c's tokenless arm and all three peer ACLs are uid==0).
# There is NO enumerate-assist for them: D8's enumerate-and-name UX exists
# because the netd terminals hard-require an interface, and these five key on
# the txn alone, so "the open window" is a total function of the domain.
# 10.E8.6 S1 (§3 M2, §7 D4): ONE probe table -- now the single source of truth
# for BOTH resolving (the 10.E8.1 verbs above) and REPORTING (`windows`).
#
# It grew by TWO rows at S1 and now covers every one of the TWELVE census
# domains, not the ten the resolvers needed:
#
#   * `pf` ($pfd_path, get_pf). get_pf has folded a full shape-A fragment
#     since Phase 4 (daemon/pfd/apply.c) -- INLINE rather than in a
#     *_pending_fragment() function, which is exactly why every census taken
#     by grepping that name recorded eleven fragments instead of twelve.
#   * `cert` ($sysd_path, get_cert), whose probe MOVED from authd to
#     sysd-direct here (D4) so the table has ONE shape and cert_pending_txn
#     stops being a hand-rolled thirteenth clone of it. The TERMINAL
#     confirm_cert/cancel_cert still ride authd's uid-0 arm, unchanged, and
#     authd's admit set is untouched (F3). The trade is stated rather than
#     hidden: a box with authd up and sysd DOWN could resolve `cert cancel`
#     with no argument before this slice and cannot after it, while the
#     likelier break-glass shape -- sysd up, authd wedged -- is unaffected in
#     either direction, because the terminal needs authd regardless.
#
# THIRTEEN ROWS for TWELVE DOMAINS: `routing` is one marker directory driven
# by two confirm pairs (ospf + bgp), so it takes two probe rows, and D7 has
# the report name the pair the operator must actually type. The report dedupes
# on (census, txn_id), so one routing window never prints twice.
#
# The three per-row keys S1 adds:
#   `domain`     the verb token, and this table's own key.
#   `census`     the window_domains[] label this row reports under
#                (daemon/authd/backup_coordinator.c) -- the join key for the
#                D3 dual-source reconciliation, and the set
#                t/ogmaprotectctl_windows.t derives from that C table and
#                asserts equality against (D9). ospf/bgp -> `routing`.
#   `enumerate`  the two domains whose producer CANNOT answer without an
#                interface (§0.11(a)): address's fragment REFUSES a NULL or
#                empty interface, and get_tunnel fails "no such tunnel
#                interface" before it ever reaches its fold. carp reads the
#                match-any get_ha_status fold, and the v6/wg fragments match
#                any interface when none is given, so those three need none.
#
# A SUB returning the table -- never a file-scope `my @T = (...)` -- for the
# reason the sub below has always carried: the dispatch calls window_request
# long before execution would reach a file-scope initializer down here (the
# reaches-the-statement trap the status constants at the top of this file
# document), so an array would read back EMPTY at run time. The `windows`
# verb exits even earlier than that, which makes the hazard sharper, not
# softer. Row ORDER is window_domains[]' order, so the report reads in the
# same order as the census string it is corroborating.
sub window_probe_table {
    return [
        { domain => 'pf',       census => 'pf',       sock => $pfd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_pf',
          pending => sub { $_[0]->{pending} } },
        { domain => 'dns',      census => 'dns',      sock => $dnsd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_dns',
          pending => sub { $_[0]->{pending} } },
        { domain => 'routes',   census => 'routes',   sock => $rtd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_routes',
          pending => sub { $_[0]->{pending} } },
        { domain => 'gateways', census => 'gateways', sock => $rtd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_gateways',
          pending => sub { $_[0]->{pending} } },
        # The D7 pair. `svc` is the marker-binding token ("ospfd"/"bgpd" --
        # NOT the "ospf"/"bgp" proto suffixes routed's own refusal string
        # uses); `other` is the sibling verb the resolver names when the
        # window belongs to the other protocol.
        { domain => 'ospf',     census => 'routing',  sock => $routed_path,
          iface_scoped => 0, enumerate => 0, op => 'get_ospf',
          svc => 'ospfd', other => 'bgp',
          pending => sub { $_[0]->{pending} } },
        { domain => 'bgp',      census => 'routing',  sock => $routed_path,
          iface_scoped => 0, enumerate => 0, op => 'get_bgp',
          svc => 'bgpd', other => 'ospf',
          pending => sub { $_[0]->{pending} } },
        # 10.E8.1 S4: ipsec's fragment is a TOP-LEVEL sibling of "enabled" in
        # get_ipsec's body (folded last, after the pf suggestion), so the
        # plain extractor applies.
        { domain => 'ipsec',    census => 'ipsec',    sock => $ipsecd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_ipsec',
          pending => sub { $_[0]->{pending} } },
        # get_cert NESTS its fragment inside the "cert" object (sysd folds it
        # there so the web countdown needs no second round trip) -- the v6
        # siting, one daemon over. The `$_[0]->{pending}` fallback is kept
        # deliberately: it is the shape authd's proxy answered with before D4
        # moved this probe to sysd-direct, and a tolerant reader costs nothing.
        { domain => 'cert',     census => 'cert',     sock => $sysd_path,
          iface_scoped => 0, enumerate => 0, op => 'get_cert',
          pending => sub {
              my $c = $_[0]->{cert};
              return (ref $c eq 'HASH' && ref $c->{pending} eq 'HASH')
                  ? $c->{pending} : $_[0]->{pending};
          } },
        { domain => 'wg',       census => 'wg',       sock => $netd_path,
          iface_scoped => 1, enumerate => 0, op => 'get_wireguard',
          pending => sub { $_[0]->{pending} } },
        { domain => 'tunnel',   census => 'tunnel',   sock => $netd_path,
          iface_scoped => 1, enumerate => 1, op => 'get_tunnel',
          pending => sub { $_[0]->{pending} } },
        { domain => 'address',  census => 'address',  sock => $netd_path,
          iface_scoped => 1, enumerate => 1, op => 'get_interface',
          pending => sub { $_[0]->{pending} } },
        # carp resolves through the match-any get_ha_status fold in its own
        # branch of window_verb_args (its fragment carries "interface", and
        # get_carp cannot answer for a windowed CREATE), so nothing in the
        # RESOLVER reads this row's op -- but the table must be TOTAL. It was
        # not when S3 first added the iface_scoped lookup above the carp
        # branch, and `carp confirm|cancel` died "no window probe for domain
        # carp" on the S3 lab pass: a shipped break-glass verb, broken by a
        # helper that only had to answer a yes/no question about the grammar
        # class. t/ogmaprotectctl_domain_verbs.t pins both interface-scoped
        # verbs against exactly that regression, and the REPORT reads this
        # row's op for real.
        { domain => 'carp',     census => 'carp',     sock => $netd_path,
          iface_scoped => 1, enumerate => 0, op => 'get_ha_status',
          pending => sub { $_[0]->{pending} } },
        # v6's fragment is NESTED inside the "ipv6" object on a direct netd
        # read (the rev2 R12 siting), so the extractor differs.
        { domain => 'v6',       census => 'v6',       sock => $netd_path,
          iface_scoped => 1, enumerate => 0, op => 'get_ipv6',
          pending => sub { ref $_[0]->{ipv6} eq 'HASH'
                               ? $_[0]->{ipv6}{pending} : undef } },
    ];
}

# TOTAL over every shipped domain, or it dies naming the domain -- the S3
# regression above. The die message is load-bearing in the other direction
# too: t/ogmaprotectctl_domain_verbs.t asserts its ABSENCE for all five
# interface-scoped verbs.
sub window_probe_spec {
    my ($domain) = @_;
    for my $row (@{ window_probe_table() }) {
        return $row if $row->{domain} eq $domain;
    }
    die "no window probe for domain $domain\n";
}

# The census labels this table covers -- the D9 set-equality observable,
# emitted under `windows --json` as census.domains. DERIVED from the table
# above and never typed a second time: a domain deleted from the table leaves
# this set, a domain smuggled in joins it, and t/ogmaprotectctl_windows.t reds
# either way against the set it parses out of window_domains[] in
# daemon/authd/backup_coordinator.c (less `dhcp`, F10). A thirteenth confirm
# domain added to that C table therefore reds HERE rather than escaping the
# report in silence.
sub window_census_labels {
    my (@out, %seen);
    for my $row (@{ window_probe_table() }) {
        push @out, $row->{census} unless $seen{ $row->{census} }++;
    }
    return @out;
}

sub window_request {
    my ($domain, $args) = @_;
    my ($sub, $iface, $txn) = window_verb_args($domain, $args);
    # 10.E8.1 S3, the domain-scoped five: NO interface key on the wire at all
    # (the five terminals key on the txn alone). Two literal `op => '...'`
    # sites per pair, never a ternary -- the t/op_contract.t constraint.
    if ($domain eq 'routes') {
        return { op => 'confirm_route', txn_id => $txn }
            if $sub eq 'confirm';
        return { op => 'cancel_route', txn_id => $txn };
    }
    if ($domain eq 'gateways') {
        return { op => 'confirm_gateways', txn_id => $txn }
            if $sub eq 'confirm';
        return { op => 'cancel_gateways', txn_id => $txn };
    }
    if ($domain eq 'dns') {
        return { op => 'confirm_dns', txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_dns', txn_id => $txn };
    }
    if ($domain eq 'ospf') {
        return { op => 'confirm_ospf', txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_ospf', txn_id => $txn };
    }
    if ($domain eq 'bgp') {
        return { op => 'confirm_bgp', txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_bgp', txn_id => $txn };
    }
    if ($domain eq 'ipsec') {
        return { op => 'confirm_ipsec', txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_ipsec', txn_id => $txn };
    }
    if ($domain eq 'address') {
        # Two literal `op => '...'` sites per pair, never a ternary: the
        # t/op_contract.t constraint cert_request records at its own sites.
        return { op => 'confirm_address', interface => $iface,
                 txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_address', interface => $iface,
                 txn_id => $txn };
    }
    if ($domain eq 'v6') {
        return { op => 'confirm_v6', interface => $iface,
                 txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_v6', interface => $iface, txn_id => $txn };
    }
    if ($domain eq 'tunnel') {
        return { op => 'confirm_tunnel', interface => $iface,
                 txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_tunnel', interface => $iface,
                 txn_id => $txn };
    }
    if ($domain eq 'wg') {
        # Verb label `wg` (the open_windows token, §7 D9); the ops stay the
        # registry literals.
        return { op => 'confirm_wireguard', interface => $iface,
                 txn_id => $txn } if $sub eq 'confirm';
        return { op => 'cancel_wireguard', interface => $iface,
                 txn_id => $txn };
    }
    return { op => 'confirm_carp', interface => $iface,
             txn_id => $txn } if $sub eq 'confirm';
    return { op => 'cancel_carp', interface => $iface, txn_id => $txn };
}

# Parse `<domain> confirm|cancel [<interface>] [<txn_id>]` and resolve whatever
# was omitted from the console-visible producer surface (§7 D8): with no
# interface, enumerate and resolve iff exactly one armed window exists in the
# domain, else die naming the armed interfaces.
sub window_verb_args {
    my ($domain, $args) = @_;
    my $sub = $args->[0];
    die "unknown $domain subcommand: " . (defined $sub ? $sub : '') . "\n"
        unless defined $sub && ($sub eq 'confirm' || $sub eq 'cancel');
    my ($iface, $txn) = @{$args}[1, 2];
    my $uuid_re = qr/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/;

    # ---- 10.E8.1 S3: the DOMAIN-SCOPED class (routes/gateways/dns/ospf/bgp)
    # Grammar is `<domain> confirm|cancel [<txn_id>]` -- the single optional
    # argument IS the txn, so there is no interface slot to disambiguate and a
    # second positional is a typo, not a second meaning. Returns iface undef;
    # window_request omits the key entirely.
    {
        my $spec = window_probe_spec($domain);
        if (!$spec->{iface_scoped}) {
            die "$domain $sub: unexpected argument '$args->[2]'"
              . " (usage: $domain $sub [<txn_id>])\n"
                if defined $args->[2];
            my $given = $iface;    # the one optional positional
            if (!defined $given) {
                my $r = window_probe($spec->{sock},
                    { op => $spec->{op}, actor => 'console' });
                my $p = ($r && $r->{ok}) ? $spec->{pending}->($r) : undef;
                $p = undef unless ref $p eq 'HASH';
                if (defined $spec->{svc}) {
                    # §7 D7 / §3 M5: the routing marker is DOMAIN-wide, so
                    # get_ospf reports a bgp window too (svc-tagged). Refuse
                    # HERE naming the other verb rather than send the other
                    # protocol's txn to this protocol's terminal -- the
                    # daemon would refuse it ("window belongs to <proto>"),
                    # but the verb must not manufacture that refusal.
                    #
                    # An UNREADABLE binding (svc "unknown") is deliberately
                    # NOT filtered: routed fails confirm CLOSED there but lets
                    # cancel fall through to the core, so a client-side
                    # refusal would strand the operator on the one direction
                    # the core keeps open. Pass it through and let the daemon
                    # adjudicate.
                    die "$domain $sub: no routing window is open"
                      . " (and no txn_id was given)\n" unless $p;
                    my $svc = $p->{svc};
                    if (defined $svc && $svc ne 'unknown'
                        && $svc ne $spec->{svc}) {
                        die "$domain $sub: the open routing window belongs to "
                          . "$spec->{other} -- run `$spec->{other} $sub`\n";
                    }
                }
                $given = $p ? $p->{txn_id} : undef;
                die "$domain $sub: no $domain window is open"
                  . " (and no txn_id was given)\n"
                    unless defined $given && $given ne '';
            }
            die "invalid txn_id\n" unless $given =~ $uuid_re;
            return ($sub, undef, $given);
        }
    }

    # A UUID in the interface slot is an omitted interface, not an interface
    # named "550e8400-...": accept it as the txn and enumerate the interface.
    if (defined $iface && $iface =~ $uuid_re) {
        die "$domain $sub: the interface comes before the txn_id\n"
            if defined $txn;
        ($iface, $txn) = (undef, $iface);
    }
    if ($domain eq 'carp') {
        my $w = window_probe($netd_path,
            { op => 'get_ha_status', actor => 'console' });
        my $p = ($w && $w->{ok} && ref $w->{pending} eq 'HASH')
            ? $w->{pending} : undef;
        if (!defined $iface) {
            die "carp $sub: no carp window is open "
              . "(and no interface was given)\n"
                unless $p && defined $p->{interface} && $p->{interface} ne '';
            $iface = $p->{interface};
        } elsif ($p && defined $p->{interface} && $p->{interface} ne $iface) {
            # Die HERE rather than manufacturing the daemon's txn-mismatch
            # refusal (the §3 M2 routing-svc rule, applied to interfaces).
            die "carp $sub: the open carp window belongs to interface "
              . "$p->{interface} -- run `carp $sub $p->{interface}`\n";
        }
        $txn = $p->{txn_id} if !defined $txn && $p;
    } else {
        my $probe = window_probe_spec($domain);
        if (!defined $iface) {
            my @armed = window_armed_ifaces($domain);
            die "$domain $sub: no $domain window is open "
              . "(and no interface was given)\n" unless @armed;
            die "$domain $sub: more than one interface has an armed window: "
              . join(', ', map { $_->[0] } @armed)
              . " -- name one: `$domain $sub <interface>`\n" if @armed > 1;
            $iface = $armed[0][0];
            $txn = $armed[0][1]{txn_id} unless defined $txn;
        } elsif (!defined $txn) {
            my $r = window_probe($probe->{sock},
                { op => $probe->{op}, interface => $iface,
                  actor => 'console' });
            my $p = ($r && $r->{ok}) ? $probe->{pending}->($r) : undef;
            $txn = (ref $p eq 'HASH') ? $p->{txn_id} : undef;
        }
    }
    die "$domain $sub: no $domain window is open on interface $iface "
      . "(and no txn_id was given)\n" unless defined $txn && $txn ne '';
    die "invalid txn_id\n" unless $txn =~ $uuid_re;
    return ($sub, $iface, $txn);
}

# One bounded direct read. Dies loudly on transport failure unless $soft --
# a resolver that reported "no window is open" because netd was unreachable
# would send the operator away from the exact daemon they must revive.
sub window_probe {
    my ($path, $req, $soft) = @_;
    my $r = eval { daemon_call($path, $req, $timeout || OGMA_CTL_CALL_TIMEOUT) };
    if (!defined $r && !$soft) {
        my $why = $@ || "no reply\n";
        chomp $why;
        die "cannot probe the open window ($req->{op}: $why)\n";
    }
    return $r;
}

# The per-interface-producer domains have no match-any read (each fragment is
# folded per interface into its get_* op), so enumeration is: list candidate
# interfaces, probe each one's producer. The confirm core admits at most one
# marker per domain, so at most one hit is the expected shape; the >1 refusal
# in the caller is the honest D8 form, not a reachable steady state. Per-iface
# probes are SOFT -- one unanswerable interface must degrade only that row,
# exactly like the status drift walk.
#
# CANDIDATE SOURCES (10.E8.1 S2): get_net names for every domain, PLUS -- for
# wg only -- the live ^wg\d+$ names from list_interfaces. A windowed wg apply
# is a gate-flagged CREATE: the wgN is LIVE while its canonical entry is
# deferred to confirm, so it appears in neither canonical nor get_net, and
# without the live listing the no-arg `wg cancel` could provably never find
# the one window it exists to resolve. list_interfaces is the same tokenless
# root-socket read class as every other probe here (P-direct); a failed live
# listing degrades to the get_net names alone (SOFT), it never fakes "no
# window".
sub window_armed_ifaces {
    my ($domain) = @_;
    my $probe = window_probe_spec($domain);
    my $net = window_probe($netd_path, { op => 'get_net', actor => 'console' });
    my @names = window_iface_names(($net && $net->{ok}) ? $net : undef);
    my %seen = map { $_ => 1 } @names;
    if ($domain eq 'wg') {
        my $live = window_probe($netd_path,
            { op => 'list_interfaces', actor => 'console' }, 1);
        for my $name (window_iface_names(($live && $live->{ok})
                                         ? $live : undef)) {
            next unless $name =~ /\Awg\d+\z/;
            push @names, $name unless $seen{$name}++;
        }
    }
    my @armed;
    for my $name (@names) {
        my $r = window_probe($probe->{sock},
            { op => $probe->{op}, interface => $name,
              actor => 'console' }, 1);
        next unless $r && $r->{ok};
        my $p = $probe->{pending}->($r);
        next unless ref $p eq 'HASH' && defined $p->{txn_id};
        # 10.E8.6 S1: the whole FRAGMENT, not just the txn -- the report needs
        # the actor, the countdown and revert_failed off the same read, and a
        # second probe to fetch them would be a second snapshot.
        push @armed, [ $name, $p ];
    }
    return @armed;
}

# The interface-name harvest out of a get_net (or list_interfaces) reply.
#
# 10.E8.6 S1: SHARED by the resolver's enumerate-assist above and the report's
# per-interface probe walk below, so the two cannot drift on what counts as a
# candidate interface. The two callers keep their own ERROR POSTURE, which is
# deliberately different and must stay that way: a resolver whose netd is
# unreachable must die loudly (sending the operator away from the daemon they
# have to revive is worse than useless), while a report must render `unknown`
# for that domain and carry on to the other eleven.
sub window_iface_names {
    my ($reply) = @_;
    my (@names, %seen);
    return @names
        unless ref $reply eq 'HASH' && ref $reply->{interfaces} eq 'ARRAY';
    for my $if (@{ $reply->{interfaces} }) {
        next unless ref $if eq 'HASH';
        my $name = $if->{name};
        next unless defined $name && !ref $name && $name ne '';
        push @names, $name unless $seen{$name}++;
    }
    return @names;
}

# ---- 10.E8.6 S1: `ogmaprotectctl windows`, the armed-window report ---------
#
# THE JOIN (Gate-0 §0 through-line). The product already computes an accurate
# window census and throws away everything about it except the domain name,
# and it already computes twelve rich per-window fragments and exposes each
# only on its own domain's surface. Neither half was missing -- the join was.
# This is the join, composed client-side from reads that ship.
#
# DUAL SOURCE (§7 D3 `C-dual`), and disagreement is RENDERED, never resolved:
#
#   * authd's marker census (`open_windows`, via get_restore_status) is the
#     SPINE. It is a direct opendir in authd's own address space, so it is
#     immune to a canonical miss and to a producer daemon being down -- and it
#     carries a compile-time label and nothing else.
#   * the twelve producers carry the DETAIL. `revert_failed` and the countdown
#     are in-RAM and PROCESS-LOCAL (daemon/common/confirm_txn.c), and the
#     durable marker's own `deadline_wall` is documented "audit/recovery only"
#     because the core's deadline is monotonic -- so no marker scan can ever
#     report a stuck window or an honest countdown, at any price.
#
# A censused domain whose producer will not answer renders `unknown` WITH THE
# REASON -- never absent, never "no window". A producer window the census does
# not name renders as a `producer` discrepancy row. Silently preferring either
# source would re-create precisely the failure open_windows_list()'s own
# comment is written against: "the operator refused and unable to tell why,
# with a positive claim of health on top".
#
# EXIT (§4 F9). 0 whether or not a window is open -- an open window does not
# degrade the box, it just cannot accept a restore, and this verb makes no
# health claim and returns no degrade code of its own. 2 is CANNOT-RUN, and it
# is reached in exactly one state: neither the census nor a single producer
# could be read, where printing "no windows" would be the positive claim of
# health over an unreadable box that D3 exists to forbid.
#
# Every probe is BOUNDED (_probe under alarm($to)). This is a break-glass verb
# and a producer that accepts a connection and never answers must not hang it
# -- cert_pending_txn's own rationale, thirteen probe rows wide.
# 10.E9 S1: `fsck-autorepair status|enable|disable`. Exit 0 on a good reply,
# 1 on a refused/failed one, 2 when the daemon could not be reached at all.
sub do_fsck_autorepair {
    my ($sub) = @_;
    $sub = 'status' unless defined $sub && $sub ne '';
    my $to = defined $timeout ? $timeout : 5;
    $to = 1  if $to < 1;
    $to = 60 if $to > 60;
    my $resp;
    my $err;
    if ($sub eq 'status') {
        ($resp, $err) = _probe($sysd_path,
            { op => 'get_fsck_setting', actor => 'console' }, $to);
    } elsif ($sub eq 'enable' || $sub eq 'disable') {
        die "fsck-autorepair $sub requires --session SID (system:power:write)\n"
            unless $session;
        die "fsck-autorepair disable requires OGMACTL_CONFIRM=1: turning "
          . "unattended repair OFF re-arms the console halt on the next "
          . "unclean boot (a headless router then stays down)\n"
            if $sub eq 'disable' && !$ENV{OGMACTL_CONFIRM};
        my $req = { op => 'set_fsck_setting', session_id => $session,
            fsck_autorepair => ($sub eq 'enable' ? JSON::PP::true
                                                 : JSON::PP::false) };
        $req->{confirm_system_power} = JSON::PP::true if $sub eq 'disable';
        $resp = eval { daemon_call($sock_path, $req, $to) };
        $err = $@ || undef if !defined $resp;
    } else {
        die "unknown fsck-autorepair subcommand: $sub (status|enable|disable)\n";
    }
    if (!defined $resp) {
        my $msg = $err // 'no reply';
        chomp $msg;
        if ($json_out) {
            print JSON::PP->new->pretty->canonical->encode({
                ok => JSON::PP::false, error => "cannot run: $msg" });
        } else {
            print STDERR "ogmaprotectctl fsck-autorepair: cannot run: $msg\n";
        }
        return 2;
    }
    if ($json_out) {
        print JSON::PP->new->pretty->canonical->encode($resp);
        return _is_true($resp->{ok}) ? 0 : 1;
    }
    if (!_is_true($resp->{ok})) {
        print STDERR "ogmaprotectctl fsck-autorepair: "
            . ($resp->{error} // 'refused') . "\n";
        return 1;
    }
    my $f = (ref $resp->{fsck_setting} eq 'HASH') ? $resp->{fsck_setting} : {};
    my $rp = $f->{rc_patch} // 'unknown';
    $rp = 'unknown' unless $rp eq 'applied' || $rp eq 'absent' || $rp eq 'unreadable';
    printf "Unattended fsck repair: %s%s\n",
        (_is_true($f->{autorepair}) ? 'on' : 'OFF (manual: a failed check halts on the console)'),
        (_is_true($f->{canonical_present}) ? '' : '  (default; not yet saved)');
    printf "rc patch:               %s\n",
        $rp eq 'applied' ? 'applied'
      : $rp eq 'absent'  ? '[NOT applied] -- stock /etc/rc halts regardless; run: ogma-fsck-rc-apply enable'
      :                    "[$rp]";
    printf "Opt-out marker:         %s%s\n",
        (_is_true($f->{marker_present}) ? 'present' : 'absent'),
        (_is_true($f->{marker_mismatch}) ? '  [DRIFT vs the saved setting -- re-save it]' : '');
    my $lr = (ref $f->{last_repair} eq 'HASH') ? $f->{last_repair} : {};
    if (_is_true($f->{repaired_this_boot})) {
        printf "[FSCK-REPAIRED %s] a filesystem was repaired unattended THIS boot\n",
            ($lr->{iso} // '?');
    }
    if (_is_true($lr->{present})) {
        printf "Last repair:            %s  fsck exit %s  lost+found %s  pf %s\n",
            ($lr->{iso} // '?'), ($lr->{fsck_exit} // '?'),
            (_is_true($lr->{lost_found}) ? 'NON-EMPTY' : 'empty'),
            (_is_true($lr->{pf_held}) ? 'HELD at bootstrap' : 'loaded');
        printf "                        %s\n", $lr->{summary}
            if defined $lr->{summary} && $lr->{summary} ne '';
    }
    printf "Auto-reboot counter:    %s\n", $f->{reboot_counter} // 0
        if ($f->{reboot_counter} // 0) > 0;
    return 0;
}

sub do_windows {
    # The `status` default and the `status` clamp, deliberately the same: this
    # verb walks a comparable number of sockets and an operator who has learned
    # one bound should not have to learn a second. THE WORST CASE IS BOUNDED AT
    # ROUGHLY (rows x $to), and the enumeration rows do NOT multiply it -- a
    # netd that cannot answer `get_net` returns early, so the per-interface
    # walk only ever runs against a netd that is demonstrably answering.
    my $to = defined $timeout ? $timeout : 3;
    $to = 1  if $to < 1;
    $to = 60 if $to > 60;

    my ($census, $raw) = window_read_census($to);
    my ($by_census, $order, $any_probe_ok) = window_probe_all($to);

    # CANNOT-RUN: no source at all answered. Say so and exit 2 rather than
    # print an empty report, which would read as "no window is open".
    if (!$census->{available} && !$any_probe_ok) {
        my $msg = 'no window source could be read -- the authd census is '
                . "unavailable ($census->{reason}) and every producer probe "
                . 'failed. This report cannot tell you whether a confirm '
                . 'window is open';
        if ($json_out) {
            print JSON::PP->new->pretty->canonical->encode({
                ok => JSON::PP::false, error => $msg });
        } else {
            print STDERR "ogmaprotectctl windows: $msg\n";
        }
        return 2;
    }

    my @rows = window_reconcile($census, $by_census, $order);

    if ($json_out) {
        # D2: `windows` is PRESENT AND EMPTY when none, never absent -- the
        # summary.restore_open_windows doctrine, one surface later. An absent
        # key is what lets a consumer read "no window" out of a report that
        # never looked.
        print JSON::PP->new->pretty->canonical->encode({
            ok      => JSON::PP::true,
            windows => \@rows,
            census  => {
                available    => ($census->{available} ? JSON::PP::true
                                                      : JSON::PP::false),
                open_windows => (defined $raw ? $raw : ''),
                labels       => $census->{labels},
                unscannable  => [ sort keys %{ $census->{unscannable} } ],
                reason       => $census->{reason},
                domains      => [ sort(window_census_labels()) ],
            },
        });
        return 0;
    }
    window_render_human(\@rows, $census);
    return 0;
}

# Source 1: authd's compile-time label census. Returns ($census, $raw).
#
# `open_windows` is a comma-joined label string, "" when none, with a `?`
# SUFFIX on any domain whose marker directory could not be scanned -- authd's
# fail-closed tri-state. The suffix is not decoration: it means that domain MAY
# be blocking and authd could not tell, so this report must not let a clean
# producer answer overwrite it (see window_reconcile).
sub window_read_census {
    my ($to) = @_;
    my ($resp, $err) = _probe($sock_path,
        { op => 'get_restore_status', actor => 'console' }, $to);
    my $rs = (ref $resp eq 'HASH' && _is_true($resp->{ok}))
        ? $resp->{restore_status} : undef;
    my $raw = (ref $rs eq 'HASH' && defined $rs->{open_windows}
               && !ref $rs->{open_windows}) ? $rs->{open_windows} : undef;
    my %c = (available => 0, reason => '', labels => [], raw => '',
             seen => {}, unscannable => {});
    if (!defined $raw) {
        $c{reason} = defined $err ? "authd: $err"
            : (ref $rs eq 'HASH')
              ? 'authd answered get_restore_status without an open_windows '
                . 'key (a build older than 10.E4 S3)'
              : 'authd returned no restore_status object';
        return (\%c, undef);
    }
    $c{available} = 1;
    $c{raw} = $raw;
    for my $tok (split /,/, $raw) {
        $tok =~ s/\A\s+//;
        $tok =~ s/\s+\z//;
        my $un = ($tok =~ s/\?\z//) ? 1 : 0;
        next unless length $tok;
        push @{ $c{labels} }, $tok unless $c{seen}{$tok}++;
        $c{unscannable}{$tok} = 1 if $un;
    }
    return (\%c, $raw);
}

# Source 2: walk every row of the probe table. Returns
# (\%by_census, \@order, $any_probe_ok).
#
# Per CENSUS label, not per row, because `routing` has two rows: the label is
# readable if EITHER of its rows answered, and its window is deduped on the
# txn so one marker never prints as two windows.
sub window_probe_all {
    my ($to) = @_;
    my (%by, @order, $any_ok);
    for my $row (@{ window_probe_table() }) {
        my $c = $row->{census};
        if (!$by{$c}) {
            $by{$c} = { ok => 0, err => undef, hits => [] };
            push @order, $c;
        }
        my ($hits, $err) = window_report_probe($row, $to);
        if (defined $err) {
            $by{$c}{err} = $err unless defined $by{$c}{err};
            next;
        }
        $by{$c}{ok} = 1;
        $any_ok = 1;
        for my $h (@$hits) {
            my $t = $h->{pending}{txn_id};
            next if grep { $_->{pending}{txn_id} eq $t } @{ $by{$c}{hits} };
            push @{ $by{$c}{hits} }, $h;
        }
    }
    return (\%by, \@order, $any_ok ? 1 : 0);
}

# ONE probe row -> (\@hits, $err). A hit is { iface, pending }.
#
# $err means "this row could not be read", which the caller renders as
# `unknown`. It is NEVER conflated with "read fine, no window open": the whole
# point of D3 is that those two are different answers and the operator is
# entitled to know which one they got.
sub window_report_probe {
    my ($row, $to) = @_;
    # A LOCAL ALIAS, not the parameter itself, and deliberately so.
    # t/op_contract.t's KIND-A helper detector reads `op => $X->{op}` and, when
    # $X's name matches one of the sub's own parameters, concludes that this
    # sub is an op-dispatch HELPER whose first argument is an op string -- so
    # every call site would then be flagged as an unresolvable op dispatch and
    # the guard would red loudly on a sub that dispatches nothing of the kind.
    # The shipped window_armed_ifaces stays clear of it by accident (its
    # `$probe` is a local); here it is on purpose, because the table row really
    # does arrive as a parameter.
    my $spec = $row;
    my @hits;

    if (!$spec->{enumerate}) {
        my ($r, $e) = _probe($spec->{sock},
            { op => $spec->{op}, actor => 'console' }, $to);
        return (\@hits, "$spec->{op}: $e") if defined $e;
        unless (ref $r eq 'HASH' && _is_true($r->{ok})) {
            my $why = (ref $r eq 'HASH' && defined $r->{error}
                       && !ref $r->{error}) ? $r->{error} : 'ok:false';
            return (\@hits, "$spec->{op} refused the read ($why)");
        }
        my $p = $spec->{pending}->($r);
        push @hits, { iface => undef, pending => $p } if window_frag_ok($p);
        return (\@hits, undef);
    }

    # ENUMERATE (§0.11(a)). address's fragment refuses a NULL/empty interface
    # outright and get_tunnel fails "no such tunnel interface" before its
    # fold, so these two must be asked one interface at a time. Candidates
    # come from the SHIPPED get_net enumerator the resolver verbs already use
    # (D8) -- this row introduces no new enumerator.
    #
    # A windowed CREATE whose canonical entry is deferred to confirm is in
    # neither canonical nor get_net, so a tunnel window can be invisible here
    # (§0.11(b)). That is a PRODUCER property this row must not repair (F5);
    # when the census names the domain and this walk finds nothing, the
    # reconciler renders `unknown` rather than clean, which is the honest
    # answer and the reason the census is the spine.
    my ($net, $neterr) = _probe($netd_path,
        { op => 'get_net', actor => 'console' }, $to);
    return (\@hits, "cannot enumerate interfaces (get_net: $neterr)")
        if defined $neterr;
    return (\@hits, 'cannot enumerate interfaces (get_net refused)')
        unless ref $net eq 'HASH' && _is_true($net->{ok})
               && ref $net->{interfaces} eq 'ARRAY';

    my (@names, %seen);
    for my $if (@{ $net->{interfaces} }) {
        next unless ref $if eq 'HASH';
        my $n = $if->{name};
        next unless defined $n && !ref $n && $n ne '';
        push @names, $n unless $seen{$n}++;
    }
    return (\@hits, undef) unless @names;

    # Per-interface probes are SOFT -- one unanswerable interface degrades
    # only its own candidacy, exactly like the status drift walk. But if NOT
    # ONE interface answered, the domain was not read at all and saying so is
    # the difference between `unknown` and a false clean.
    # A partial wedge -- netd answered `get_net` and then stopped answering
    # per-interface reads -- is the one shape that could multiply $to by the
    # interface count on a break-glass path. Three consecutive transport
    # failures against the SAME socket is not an unlucky interface, it is a
    # daemon that has stopped answering, so stop asking and say so.
    my ($tried, $answered, $miss) = (0, 0, 0);
    for my $n (@names) {
        $tried++;
        my ($r, $e) = _probe($spec->{sock},
            { op => $spec->{op}, interface => $n, actor => 'console' }, $to);
        if (defined $e) {
            last if ++$miss >= 3;
            next;
        }
        $miss = 0;
        $answered++;
        next unless ref $r eq 'HASH' && _is_true($r->{ok});
        my $p = $spec->{pending}->($r);
        push @hits, { iface => $n, pending => $p } if window_frag_ok($p);
    }
    return (\@hits, "no interface answered $spec->{op} ($tried tried)")
        if $tried && !$answered;
    return (\@hits, undef);
}

# A fragment is usable iff it is an object carrying a non-empty scalar txn_id.
# A `pending` that carries no txn is not a window this report can name, and
# naming it anyway would hand the operator a row they cannot resolve.
sub window_frag_ok {
    my ($p) = @_;
    return 0 unless ref $p eq 'HASH';
    my $t = $p->{txn_id};
    return (defined $t && !ref $t && $t ne '') ? 1 : 0;
}

# The D3/M4 reconciliation. Census x producers -> the row list.
sub window_reconcile {
    my ($census, $by, $order) = @_;
    my @rows;

    for my $c (@$order) {
        my $e = $by->{$c};
        if (@{ $e->{hits} }) {
            for my $h (@{ $e->{hits} }) {
                my $src = $census->{seen}{$c} ? 'both' : 'producer';
                my $why = '';
                if (!$census->{seen}{$c}) {
                    # DISCREPANCY. Not an error and not a lie -- most often a
                    # window that armed between the census read and this probe
                    # (§8 R1: the report is a snapshot over unsynchronized
                    # reads, by design), which the operator sees as a race
                    # rather than as a wrong answer.
                    $why = $census->{available}
                        ? 'DISCREPANCY: the producer reports this window but '
                        . 'the authd census does not name the domain -- it '
                        . 'armed after the census was taken, or authd cannot '
                        . 'see the marker directory'
                        : 'uncorroborated: the authd census is unavailable, '
                        . 'so nothing cross-checks this row';
                }
                push @rows, window_report_row($c, $h, $src, $why);
            }
            next;
        }
        next unless $census->{seen}{$c};

        # Censused, and this report has no window for it. Two very different
        # reasons, and the operator gets told which.
        my $why = $e->{ok}
            ? 'the producer answered and reports NO open window -- a race '
            . 'with a resolve in flight, or a window whose canonical entry '
            . 'is deferred to confirm and which the producer cannot see'
            : ($e->{err} // 'the producer could not be read');
        $why = 'the authd census could NOT SCAN this marker directory, so '
             . "the domain may be blocking and authd could not tell; $why"
            if $census->{unscannable}{$c};
        push @rows, window_unknown_row($c, $why);
    }

    # A censused label with no probe row at all. `dhcp` is the standing case
    # and it is named rather than dropped (F10): the census reports it because
    # the v4-DHCP saga rides the same chokepoint, and a report that silently
    # discarded a label the census printed would fire M4's discrepancy rule
    # against itself every time a DHCP saga is in flight.
    for my $lab (@{ $census->{labels} }) {
        next if $by->{$lab};
        push @rows, window_unknown_row($lab, $lab eq 'dhcp'
            ? 'the v4-DHCP saga rides the same restore chokepoint but is NOT '
            . 'a confirm domain: no txn_id, no deadline and no confirm|cancel '
            . 'verb. It still blocks a restore. Resolve it through the DHCP '
            . 'surface, not from here'
            : 'the census names a domain this report has no probe for -- a '
            . 'confirm domain reached window_domains[] and not the probe '
            . 'table');
    }
    return @rows;
}

# M3: four producer fragment shapes collapse to ONE row here, client-side.
#
# `remaining_ms` is deliberately NOT lifted: tunnel is its only emitter, the
# web countdown owns that key, and a report that carried it for one domain of
# twelve would invite exactly the shape drift this surface exists to end.
sub window_report_row {
    my ($census, $hit, $source, $reason) = @_;
    my $p   = $hit->{pending};
    my $svc = window_str($p->{svc});
    my $domain = $census;

    # D7 `G-report-svc`: name the confirm pair the operator must TYPE, not the
    # marker-granular census label. An unreadable binding stays `routing` and
    # says so -- routed fails `confirm` closed there but lets `cancel` fall
    # through, so pretending to know would strand the operator on the one
    # direction the core keeps open.
    if ($census eq 'routing') {
        $domain = $svc eq 'ospfd' ? 'ospf'
                : $svc eq 'bgpd'  ? 'bgp'
                :                   'routing';
        if ($domain eq 'routing') {
            $reason = ($reason ne '' ? "$reason; " : '')
                    . "the marker's svc binding is unreadable, so this report "
                    . 'cannot say which confirm pair owns the window: `ospf '
                    . 'cancel` and `bgp cancel` both reach it, `confirm` does '
                    . 'not';
        }
    }

    # §0.5's load-bearing corner: `address` is interface-scoped and its
    # fragment OMITS `interface`, so the reporter supplies the interface it
    # probed. Client-side normalization, NOT a producer repair (F5).
    my $iface = window_str($p->{interface});
    $iface = $hit->{iface}
        if $iface eq '' && defined $hit->{iface} && $hit->{iface} ne '';

    return {
        domain        => $domain,
        txn_id        => window_str($p->{txn_id}),
        interface     => $iface,
        svc           => $svc,
        actor         => window_str($p->{actor}),
        deadline      => window_num($p->{deadline}),
        remaining     => window_num($p->{remaining}),
        revert_failed => window_bool($p->{revert_failed}),
        source        => $source,
        reason        => $reason,
    };
}

# The `unknown` row (M4). Every key is present and the unknowable ones are
# NULL rather than 0/false: a report that emitted `revert_failed:false` for a
# window it could not read would be making the positive claim of health this
# whole surface is designed against.
sub window_unknown_row {
    my ($domain, $reason) = @_;
    return {
        domain        => $domain,
        txn_id        => '',
        interface     => '',
        svc           => '',
        actor         => '',
        deadline      => undef,
        remaining     => undef,
        revert_failed => undef,
        source        => 'census',
        reason        => $reason,
    };
}

sub window_str {
    my ($v) = @_;
    return (defined $v && !ref $v) ? "$v" : '';
}

# undef (JSON null) for anything that is not a plain integer on the wire --
# "not known" and "zero seconds left" are different facts.
sub window_num {
    my ($v) = @_;
    return undef unless defined $v && !ref $v && $v =~ /\A-?\d+\z/;
    return 0 + $v;
}

# TRI-STATE. Absent/undef -> null; anything else -> the JSON boolean.
sub window_bool {
    my ($v) = @_;
    return undef unless defined $v;
    return _is_true($v) ? JSON::PP::true : JSON::PP::false;
}

# The human surface. Legibility under lockout pressure is a stated design
# concern, so: the count first, the census string second (it is the answer to
# "which domains", and it is what `ogmaprotectctl status` already prints), a
# fixed-column table third, and every non-`both` row's reason on its own
# marked line under it rather than widening the table.
sub window_render_human {
    my ($rows, $census) = @_;
    my @open = grep { $_->{source} ne 'census' } @$rows;
    my @unresolved = grep { $_->{source} eq 'census' } @$rows;

    # THE HEADLINE MUST NOT READ AS HEALTH WHILE A ROW BELOW SAYS `unknown`.
    # An earlier draft printed "open confirm windows: none" above a table
    # whose one row was a censused domain nobody could read -- which is the
    # positive-claim-of-health failure this whole surface is designed against,
    # reproduced in the one line an operator under pressure actually reads.
    if (@unresolved) {
        printf "open confirm windows: %d reported, %d UNRESOLVED "
             . "(the `!` rows below -- this report could not rule them out)\n",
            scalar @open, scalar @unresolved;
    } else {
        printf "open confirm windows: %s\n", @open ? scalar(@open) : 'none';
    }
    if ($census->{available}) {
        # The RAW authd string, `?` suffixes and all -- not the parsed labels.
        # That suffix is authd's own fail-closed marker for a marker directory
        # it could not scan, and dropping it here would hide exactly the
        # uncertainty the marked row below is about.
        printf "authd census: %s\n",
            $census->{raw} ne '' ? $census->{raw} : '(none)';
    } else {
        printf "authd census: UNAVAILABLE (%s)\n", $census->{reason};
        print  "  ! without the census this report cannot name a domain whose "
             . "producer it failed to reach\n";
    }

    if (!@$rows) {
        print "\nNo confirm window is open. A restore is not blocked by one.\n";
        return;
    }

    print "\n";
    printf "%-8s %-10s %-10s %9s %-6s %-8s %s\n",
        'DOMAIN', 'INTERFACE', 'ACTOR', 'REMAINING', 'REVERT', 'SOURCE',
        'TXN_ID';
    my $stuck = 0;
    for my $r (@$rows) {
        $stuck = 1 if $r->{revert_failed};
        printf "%-8s %-10s %-10s %9s %-6s %-8s %s\n",
            $r->{domain},
            ($r->{interface} ne '' ? $r->{interface} : '-'),
            ($r->{actor}     ne '' ? $r->{actor}     : '-'),
            (defined $r->{remaining} ? $r->{remaining} . 's' : '-'),
            (defined $r->{revert_failed}
                ? ($r->{revert_failed} ? 'YES' : 'no') : '-'),
            $r->{source},
            ($r->{txn_id} ne '' ? $r->{txn_id} : 'unknown');
        print "  ! $r->{domain}: $r->{reason}\n" if $r->{reason} ne '';
    }

    return unless @open;
    print "\nresolve one with (the txn is OPTIONAL -- omit it and the verb "
        . "re-derives it):\n"
        . "  ogmaprotectctl <domain> confirm|cancel [<interface>] [<txn_id>]\n";
    print "REVERT=YES means the auto-revert did NOT complete: the box is not "
        . "on\nthe pre-change configuration and confirming will not make it "
        . "so. Read the\naudit trail before you resolve that window.\n"
        if $stuck;
}

sub slurp_pem {
    my ($file, $max, $what) = @_;
    my $sz = -s $file;
    die "read $what $file: $!\n" unless defined $sz;
    die "$what $file is ${sz} bytes; the limit is $max\n" if $sz > $max;
    open my $fh, '<', $file or die "read $what $file: $!\n";
    local $/;
    my $b = <$fh>;
    close $fh;
    die "$what $file is empty\n" unless defined $b && $b ne '';
    return $b;
}

# ---- status (I-02) -------------------------------------------------------

sub _now_utc {
    my @t = gmtime();
    return sprintf("%04d-%02d-%02dT%02d:%02d:%02dZ",
        $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0]);
}

sub _hostname {
    my $h = eval { require Sys::Hostname; Sys::Hostname::hostname() };
    return (defined $h && $h ne '') ? $h : 'unknown';
}

sub _classify_conn {
    my $e = shift;
    $e = '' unless defined $e;
    return 'not running (no socket)'   if $e =~ /No such file|does not exist/i;
    return 'down (connection refused)' if $e =~ /refused/i;
    return 'permission denied'         if $e =~ /permission denied/i;
    return $e ne '' ? $e : 'unreachable';
}

# 10.E6 VD-E6-22: classify the installed package's build release against the
# running one. PURE: ($release_info_text_or_undef, $running_release) ->
#   { state => match | mismatch | unknown, built_for => '7.9'|'' , version => .. }
# `unknown` covers every shape that cannot be compared -- no file, an empty or
# malformed file, `built_for_openbsd: unknown` (a non-OpenBSD build), or a
# running release that could not be read -- and is NEVER rendered as a match.
# Parsing is the RELEASE-INFO rule (scripts/check-release-match.sh): `key: value`,
# whole-line `#` comments ignored, a trailing `#` is PART of the value (so a
# value that carries one is a mismatch, loudly, rather than a guessed comment
# convention). Compared as exact strings: "7.9" ne "7.9-stable", by design.
sub _release_match {
    my ($text, $running) = @_;
    my %kv;
    if (defined $text) {
        for my $line (split /\n/, $text) {
            $line =~ s/\r\z//;
            next if $line =~ /\A\s*#/ || $line !~ /\S/;
            next unless $line =~ /\A([A-Za-z0-9_-]+): (.*)\z/;
            $kv{$1} = $2 unless exists $kv{$1};   # first wins, like `head -1`
        }
    }
    my $built = defined $kv{built_for_openbsd} ? $kv{built_for_openbsd} : '';
    my $ver   = defined $kv{version} ? $kv{version} : '';
    my $state;
    if ($built eq '' || $built eq 'unknown'
        || !defined $running || $running eq '') {
        $state = 'unknown';
    } else {
        $state = ($built eq $running) ? 'match' : 'mismatch';
    }
    return { state => $state, built_for => $built, version => $ver };
}

# The running release, from the kernel via POSIX::uname (core perl, no exec):
# (sysname, nodename, release, version, machine). Empty on anything odd.
sub _running_release {
    my @u = eval { require POSIX; POSIX::uname() };
    return (defined $u[2] && defined $u[0] && $u[0] eq 'OpenBSD') ? $u[2] : '';
}

# Bounded probe: connect + one request + one response line, all under
# alarm($timeout) so a wedged single-threaded daemon can never hang the run.
# Returns ($resp_hashref_or_undef, $err_or_undef, $was_permission_denied).
sub _probe {
    my ($sock, $req, $to) = @_;
    my ($resp, $err, $perm) = (undef, undef, 0);
    eval {
        local $SIG{ALRM} = sub { die "ALRMTIMEOUT\n" };
        alarm($to);
        my $s = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $sock);
        if (!$s) {
            my $se = "$!";
            $perm = 1 if $se =~ /permission denied/i;
            die "CONNFAIL:$se\n";
        }
        print $s JSON::PP->new->encode($req), "\n";
        my $line = <$s>;
        close $s;
        die "NORESP\n" if !defined $line;
        chomp $line;
        $resp = JSON::PP->new->decode($line);
        1;
    } or do {
        my $ex = $@ || 'error';
        if    ($ex =~ /^ALRMTIMEOUT/)    { $err = 'wedged (timeout)'; }
        elsif ($ex =~ /^CONNFAIL:(.*)/s) { $err = _classify_conn($1); }
        elsif ($ex =~ /^NORESP/)         { $err = 'no response'; }
        else                             { $err = 'bad response'; }
    };
    alarm(0);
    return ($resp, $err, $perm);
}

# JSON-boolean-true test: the drift contract is boolean-only, so a string
# "false" (which is Perl-truthy) must NOT count as drift.
sub _is_true {
    my $v = shift;
    return 0 if !defined $v;
    return ($v ? 1 : 0) if ref $v eq 'JSON::PP::Boolean';
    return ($v eq '1' || lc("$v") eq 'true') ? 1 : 0;
}

# 10.C6 S6d: the recursive boolean _drift_keys walk is RETIRED — every DOMAIN
# emitter now ships the unified "drift":{verdict,...} object, read explicitly by
# _domain_drift. See the fail-closed terminal there.

# 10.C6 S2: classify a domain's get_* reply (ok:true) into (state, keys, detail).
# A real boolean drift key wins => DRIFT. Otherwise a reply that could not fully
# read the live OS — routes emits scan_incomplete (a truncated FIB stream) or
# scan_error (a positively-unreadable configured route) — is ERROR (could-not-
# determine), NEVER a false CLEAN. Else CLEAN. This is the domain-walk analogue
# of _iface_verdict's fail-closed drift-object reader; a real drift must still
# surface even mid-uncertainty (a genuine divergence is not masked by "could not
# read").
sub _domain_drift {
    my ($dr) = @_;
    # 10.C6 S3 (F1) + S6a (A3/M8): the UNIFIED drift object
    # "drift":{verdict,reason,has_drift,has_error,facets}. The boolean _drift_keys
    # walk below catches has_drift (its name ends in _drift) but NOT has_error, and
    # the verdict/facet values are strings it cannot read — so an ERROR-only reply
    # (e.g. an ipsec PKI error with no config drift) would read CLEAN, reinstating
    # the L3-04 false-clean. When the object is present, read it EXPLICITLY, in
    # the _iface_verdict order: has_drift wins (drift-dominant, drift.h M1 —
    # deliberately BEFORE token validation, so a real drift is never demoted to
    # error by a corrupt verdict), then has_error => error, then ONLY a valid
    # non-degrading token ('ok'/'skip') reaches clean; anything else — absent/
    # undef/non-scalar/unknown verdict, or an in-set 'drift'/'error' token whose
    # decision booleans are absent/false (an internally-inconsistent object) —
    # is fail-closed ERROR, never clean (M8). This also closes the legacy
    # nested-time shape (a drift HASH with no verdict) that read clean since S3.
    # Legacy boolean domains fall through below.
    if (ref($dr->{drift}) eq 'HASH') {
        my $d = $dr->{drift};
        my $rsn = (defined $d->{reason} && !ref $d->{reason}
            && $d->{reason} ne '') ? $d->{reason} : undef;
        if (_is_true($d->{has_drift})) {
            # 10.C6 S6a (A4): name WHICH facets drifted — schema-controlled
            # identifiers only (never free daemon text in the drift arm, the
            # iface-arm discipline); ['drift'] when facets are absent or
            # malformed so a DRIFT row can never render empty parens.
            my @fk;
            if (ref $d->{facets} eq 'HASH') {
                @fk = sort grep {
                    defined $d->{facets}{$_} && !ref $d->{facets}{$_}
                        && $d->{facets}{$_} eq 'drift'
                } keys %{ $d->{facets} };
            }
            @fk = ('drift') if !@fk;
            return ('drift', \@fk, $rsn);
        }
        return ('error', [], $rsn // 'could not determine live state')
            if _is_true($d->{has_error});
        my $v = $d->{verdict};
        return ('clean', [], undef)
            if defined $v && !ref $v && ($v eq 'ok' || $v eq 'skip');
        return ('error', [],
            (defined $v && !ref $v && ($v eq 'drift' || $v eq 'error'))
            ? 'inconsistent drift object'
            : ($rsn // 'no valid drift verdict'));
    }
    # 10.C6 S6d (M8b): every DOMAIN emitter now ships the unified drift object —
    # S6a-d migrated the last flat-boolean domains (interfaces + routes). A reply
    # WITHOUT a top-level drift object is a daemon that predates the schema (a
    # partial-upgrade skew) or a corrupted reply, so fail CLOSED to ERROR, never
    # clean. The legacy recursive _drift_keys boolean walk AND the routes
    # scan_incomplete/scan_error special case are RETIRED here — their signals now
    # ride the object's has_drift / has_error (routes: configured_present ERROR
    # folds scan_error/scan_incomplete; the walk sees them via has_error).
    return ('error', [], 'no unified drift verdict (daemon predates schema?)');
}

# 10.C6 S6d (M1/D13): the box-health verdict as a PURE function of the collected
# facts, defined ABOVE do_status so t/ogmaprotectctl_status.t can unit-pin it (the
# do_status body itself is not eval'd). A DOMAIN read-error now degrades health the
# same way a per-iface 'error' already does (closing the deliberate divergence the
# iface walk called out) — domains_error must be 0 for a healthy box.
sub _health_verdict {
    my (%f) = @_;
    return ($f{up} == $f{total}
        && ($f{domains_drifted} // 0) == 0
        && ($f{domains_error} // 0) == 0
        && ($f{iface_drift_count} // 0) == 0
        && !$f{integ_degraded} && !$f{restore_degraded}
        && !$f{schema_degraded}
        && $f{version_consistent} && !$f{db_bad}) ? 1 : 0;
}

# ---- per-interface HA/VPN drift (9.I-02b) ---------------------------------
# The specialised carp/pfsync/wireguard/tunnel/pppoe drift ops (netd) emit the
# unified "drift":{...} object (10.C6 S5a-S5c) and take an `interface` argument,
# so unlike the DOMAIN walk they need their own enumeration (from get_net's kind
# field) + their own per-iface drift-object reader (_iface_verdict).

# Bucket the get_net "interfaces" list into the HA/VPN kinds we have a
# per-interface drift op for. Returns { kind => [iface_name, ...] } for the
# kinds actually present (carp/wireguard/tunnel/pppoe/pfsync only). Schema-
# defensive: a missing/malformed list yields {}.
sub _bucket_ifaces_by_kind {
    my ($net) = @_;
    my %buckets;
    return \%buckets unless ref $net eq 'HASH';
    my $list = $net->{interfaces};
    return \%buckets unless ref $list eq 'ARRAY';
    for my $if (@$list) {
        next unless ref $if eq 'HASH';
        my ($name, $kind) = ($if->{name}, $if->{kind});
        next unless defined $name && defined $kind && !ref $name && !ref $kind;
        next unless $kind =~ /\A(?:carp|wireguard|tunnel|pppoe|pfsync)\z/;
        push @{ $buckets{$kind} }, $name;
    }
    return \%buckets;
}

# Map a per-interface HA/VPN drift op response to (state, verdict, detail).
#   state ∈ clean|drift|error|limited|unknown ; verdict = the roll-up token (for
#   the machine-readable iface_drift[].verdict) or undef ; detail = a FIXED string.
# FAIL-CLOSED: a missing/malformed verdict, ok:false, or an unrecognized value
# maps to error/degraded — NEVER clean. The daemon reason strings are deliberately
# never echoed — the console output contract stays stable.
#
# 10.C6 S5a-S5c: ALL five kinds emit the unified
# "drift":{verdict,has_drift,has_error,facets} OBJECT — read it EXPLICITLY
# (has_drift wins drift-dominant, then has_error, then a bare SKIP roll-up =
# "could not fully verify here" -> limited, else clean). carp was the last
# string emitter (folded by S5c), so the legacy per-kind string allowlist is
# GONE: any reply without a drift object fails closed to error (M8) — a stale
# pre-migration string from an old netd is never read as clean. wireguard keeps
# a live_present fallback for the transient install-skew window (a pre-S5a netd
# with no drift object).
sub _iface_verdict {
    my ($kind, $resp) = @_;
    # No response (connect fail / timeout / bad JSON) -> unknown (not counted
    # toward degraded; a wedged/absent netd already shows as a DOWN daemon).
    return ('unknown', undef, 'no response') unless ref $resp eq 'HASH';
    # ok:false = the op refused (e.g. "no such <kind> interface") -> error.
    # This guard MUST stay ABOVE the shape-dispatch + the legacy fallback so an
    # ok:false reply (which carries no drift object) always fails closed.
    return ('error', undef, 'could not read live interface state')
        unless _is_true($resp->{ok});

    # (1) the unified drift object (migrated kinds). Decision-path signals
    #     has_drift/has_error, NOT the roll-up token alone (contract M1); a bare
    #     SKIP roll-up -> limited (e.g. the console cannot verify the wg key).
    if (ref $resp->{drift} eq 'HASH') {
        my $d = $resp->{drift};
        my $verd = $d->{verdict};
        return ('drift', $verd, 'configuration drift')
            if _is_true($d->{has_drift});
        return ('error', $verd, 'could not read live interface state')
            if _is_true($d->{has_error});
        return ('limited', $verd, 'present; not fully verifiable here')
            if defined $verd && $verd eq 'skip';
        return ('clean', $verd, undef)
            if defined $verd && $verd eq 'ok';
        return ('error', $verd, 'unrecognized verdict');   # fail closed (M8)
    }

    # (2) install-skew fallback: a pre-S5a netd emits no drift object. WireGuard
    #     keeps its live_present read (the console cannot derive the server key,
    #     so a present tunnel is 'limited', absent is 'drift', never 'clean').
    if ($kind eq 'wireguard') {
        return _is_true($resp->{live_present})
            ? ('limited', undef,
               'key check via web UI; present, key not checked here')
            : ('drift', undef, 'configured wireguard interface not present live');
    }

    # (3) fail-closed terminal (M8). 10.C6 S5c migrated carp — the LAST string
    #     emitter — to the unified drift object (read above), so EVERY kind
    #     without a drift object lands here: a stale pre-migration reply (e.g.
    #     a carp_status/pfsync_status string from an old netd during the
    #     partial-install window) reads error, never clean.
    return ('error', undef, 'could not read live interface state');
}

# Coerce a JSON number/string to a non-negative int; anything odd => 0.
sub _int {
    my $v = shift;
    return 0 unless defined $v && !ref $v;
    return ($v =~ /\A\d+\z/) ? ($v + 0) : 0;
}

# Map a get_config_integrity (L8-08) response to (state, degrade, detail).
#   state   ∈ clean | warn | mismatch | malformed | absent | unsupported |
#             unavailable
#   degrade = 1 iff this should flip the box to degraded (exit 1)
#   detail  = a short fixed summary string (never echoes daemon free-text)
# FAIL-CLOSED but not alarmist. A missing / ok:false / garbage response =>
# 'unavailable' advisory (a wedged authd already shows as its own DOWN row).
# Only a confirmed content mismatch, an unhashable fragment, or a corrupt
# manifest degrade the box. STALE (a fragment newer than the manifest — benign
# non-atomic write window), MISSING (a recorded fragment since removed), ABSENT
# (no manifest yet) and UNSUPPORTED (a newer format this tool can't read) are
# advisory only, so a legitimate config change never falsely fails the check.
sub _integrity_verdict {
    my ($resp) = @_;
    return ('unavailable', 0, 'no response')
        unless ref $resp eq 'HASH' && _is_true($resp->{ok});
    my $it = $resp->{integrity};
    return ('unavailable', 0, 'no integrity data')
        unless ref $it eq 'HASH';
    my $st = $it->{status};
    $st = '' unless defined $st && !ref $st;
    my $sum = (ref $it->{summary} eq 'HASH') ? $it->{summary} : {};

    if ($st eq 'ok') {
        my $mm = _int($sum->{mismatch});
        my $er = _int($sum->{error});
        return ('mismatch', 1, sprintf('%d mismatch, %d error', $mm, $er))
            if $mm > 0 || $er > 0;
        my $stl = _int($sum->{stale});
        my $mis = _int($sum->{missing});
        if ($stl > 0 || $mis > 0) {
            my @n;
            push @n, "$stl stale"  if $stl > 0;
            push @n, "$mis missing" if $mis > 0;
            return ('warn', 0, join(', ', @n));
        }
        return ('clean', 0, undef);
    }
    return ('malformed',   1, 'manifest corrupt')             if $st eq 'malformed';
    return ('absent',      0, 'no manifest.yaml')             if $st eq 'absent';
    return ('unsupported', 0, 'manifest format not supported') if $st eq 'unsupported';
    return ('unavailable', 0, 'unknown status');   # fail closed (advisory)
}

# Map a get_restore_status (10.A4) response to (state, degrade, detail).
#   state   ∈ idle | recovered | incomplete | active | unavailable
#   degrade = 1 iff this should flip the box to degraded (exit 1)
# Only a genuinely unresolved restore transaction degrades: state=incomplete
# (recovery could not resolve the journal — reverse or forward pending), or
# the fail-closed inconsistency of a journal on disk while authd reports
# idle. A cleanly RECOVERED record is advisory (the operator should read the
# audit trail, but the box is consistent), and restoring/recovering are
# transient states a status poll can only see mid-flight (advisory).
sub _restore_verdict {
    my ($resp) = @_;
    return ('unavailable', 0, 'no response')
        unless ref $resp eq 'HASH' && _is_true($resp->{ok});
    my $rs = $resp->{restore_status};
    return ('unavailable', 0, 'no restore data')
        unless ref $rs eq 'HASH';
    my $st = $rs->{state};
    $st = '' unless defined $st && !ref $st;
    my $dir = (defined $rs->{direction} && !ref $rs->{direction})
        ? $rs->{direction} : 'none';
    my $journal = _is_true($rs->{journal_present}) ? 1 : 0;

    # 10.E4 S3 (§7 D5, VD-E4-3): the open confirm-window census authd computes
    # live. `// ''` is load-bearing -- t/ogmaprotectctl_status.t runs the real
    # script and asserts its output carries no "uninitialized value" warning,
    # and this key is absent against any authd older than S3.
    my $windows = (defined $rs->{open_windows} && !ref $rs->{open_windows})
        ? $rs->{open_windows} : '';

    if ($st eq 'incomplete') {
        my $failed = (defined $rs->{failed} && !ref $rs->{failed}
            && length $rs->{failed}) ? $rs->{failed} : undef;
        return ('incomplete', 1, "$dir recovery pending"
            . (defined $failed ? " ($failed)" : ""));
    }
    return ('incomplete', 1, 'journal present') if $journal;
    # An open window does NOT degrade the box -- it is idle and healthy; it just
    # cannot accept a restore until the window is confirmed or cancelled. But it
    # must be VISIBLE, because a console restore refused by one is otherwise a
    # silent refusal (§7 D5 accepted the wedge, not the silence).
    return ('idle', 0, "confirm window open: $windows (a restore is blocked "
        . "until it is confirmed or cancelled)")
        if $st eq 'idle' && length $windows;
    return ('idle',      0, undef)                    if $st eq 'idle';
    return ('recovered', 0, "$dir recovery completed") if $st eq 'recovered';
    return ('active', 0, 'restore in progress')
        if $st eq 'restoring' || $st eq 'recovering';
    return ('unavailable', 0, 'unknown state');   # fail closed (advisory)
}

# Map a get_schema_state (10.E2 S2) response to (state, degrade, detail).
#   state   ∈ clean | regressed | ahead | torn | malformed | unreadable |
#             unavailable
#   degrade = 1 iff this should flip the box to degraded (exit 1)
#   detail  = a short fixed summary string (never echoes daemon free-text)
#
# FAIL-CLOSED but not alarmist, exactly like _integrity_verdict on the same
# directory. What degrades and what does NOT is load-bearing:
#
#   ahead      a fragment written by a NEWER build than this one — the
#              MECH-DEGRADE condition (Gate-0 §7 D4). Actionable: the box was
#              downgraded, or a config was restored from a newer build.
#   torn       ospf.yaml and bgp.yaml at DIFFERENT versions — a crash between
#              the routing fragment's two writes (§3 T2 E2M-4).
#   malformed  a version scalar the owner cannot parse (or, for dns, a required
#              version: key that is absent) — the file will not load.
#   unreadable an I/O error on a fragment that exists.
#
#   regressed  on-disk BELOW the ceiling. This is the ratified P-lazy steady
#              state (D9): after an upgrade the disk legitimately stays old and
#              re-converges at the next owner save. Degrading here would read
#              EVERY upgraded box as degraded, which is precisely what D9 chose
#              against — so it is advisory only.
#   absent     a fragment file that does not exist. Six of the sixteen have no
#              boot seeder, so this is the NORMAL state on a healthy box.
sub _schema_verdict {
    my ($resp) = @_;
    return ('unavailable', 0, 'no response')
        unless ref $resp eq 'HASH' && _is_true($resp->{ok});
    my $ss = $resp->{schema_state};
    return ('unavailable', 0, 'no schema data')
        unless ref $ss eq 'HASH';
    my $st = $ss->{status};
    $st = '' unless defined $st && !ref $st;
    return ('unavailable', 0, 'unknown status') unless $st eq 'ok';
    my $sum = (ref $ss->{summary} eq 'HASH') ? $ss->{summary} : {};

    # A summary we cannot read must NOT report clean. status:"ok" with an
    # unreadable/absent summary is a skewed or truncated daemon, and the whole
    # verdict below is computed from these counters — reading a missing summary
    # as "all zero" would print an affirmative green and exit 0 for a box we
    # learned nothing about. Same fail-closed posture the drift-manifest reader
    # takes on an unparsable manifest.
    return ('unavailable', 0, 'no summary')
        unless ref $ss->{summary} eq 'HASH'
            && defined $sum->{total} && !ref $sum->{total};

    # Worst-first, so the reported state names the most actionable condition.
    my $un = _int($sum->{unreadable});
    return ('unreadable', 1, "$un unreadable") if $un > 0;
    my $mal = _int($sum->{malformed});
    return ('malformed', 1, "$mal malformed") if $mal > 0;
    # AHEAD outranks TORN deliberately. Both can be true at once (a half-written
    # routing bump leaves one file newer than this build AND the two halves
    # disagreeing), and the torn remedy is "re-save the routing config" — which
    # on a box that also has an ahead half would overwrite the NEWER half with
    # what this older build can render. Naming `ahead` first keeps the
    # destructive advice behind the upgrade advice.
    my $ahead = _int($sum->{ahead});
    return ('ahead', 1, "$ahead newer than this build") if $ahead > 0;
    my $torn = _int($sum->{torn});
    return ('torn', 1, "$torn torn") if $torn > 0;
    my $reg = _int($sum->{regressed});
    return ('regressed', 0, "$reg below ceiling") if $reg > 0;
    return ('clean', 0, undef);
}

# `ogmaprotectctl schema` exit status, as a PURE function of the reply so
# t/ogmaprotectctl_status.t can pin the contract (a monitoring verb whose exit
# does not carry the verdict is worse than no check at all — the audit-verify
# doctrine). 0 = every fragment at a version this build supports, 1 = at least
# one ahead/torn/malformed/unreadable, 2 = the op itself could not run.
sub _schema_exit {
    my ($resp) = @_;
    return 2 unless ref $resp eq 'HASH' && _is_true($resp->{ok});
    return 2 unless ref $resp->{schema_state} eq 'HASH';
    my ($state, $degrade) = _schema_verdict($resp);
    return 2 if $state eq 'unavailable';
    return $degrade ? 1 : 0;
}

# What an operator should DO about a degrading schema state. Fixed strings, never
# daemon free-text; every degrading state has one, so a cron consumer that sees
# exit 1 is never left without a next step.
sub _schema_remedy {
    my ($state) = @_;
    # 10.E2 S4: this used to end "(see RELEASE.md)". No .md file is installed on
    # a box -- `make install` installs no .md file and the pkg PLIST
    # carries no .md row -- so the pointer was dead exactly
    # where an operator would follow it. Name the on-box artifact instead, and
    # let the release documentation be found by name rather than by path.
    return 'a fragment is newer than this build - upgrade this box, or restore '
         . 'the pre-upgrade snapshot of /var/db/ogmaprotect (procedure: the '
         . 'Upgrade and rollback section of the release documentation for your '
         . 'build)'                                         if $state eq 'ahead';
    # Deliberately does NOT say "just re-save": on a box that is ALSO ahead the
    # re-save would overwrite the newer half. The ahead verdict outranks torn so
    # that case reads the line above instead, and this line still says to check.
    return 'ospf.yaml and bgp.yaml are at different versions - check neither is '
         . 'newer than this build, then re-save the routing config to converge '
         . 'both halves'                                    if $state eq 'torn';
    return 'a fragment version line is unparsable - inspect that file; the '
         . 'owning daemon cannot load it either'            if $state eq 'malformed';
    return 'a fragment could not be read - check permissions and disk health'
                                                            if $state eq 'unreadable';
    return undef;
}

# ---- 10.C9 / L1-22: persisted boot-order verdict (advisory) ------------------
# @BOOT_DEPS itself is declared ABOVE the `status` dispatch (next to the drift
# constants), NOT here: a file-scope initializer below that dispatch never runs
# on a real `status`, and an empty pair list makes _bootorder_verdict fall
# through to a confident `ok` for ANY boot order. See the declaration for the
# full note.

# Parse the ordered ogmaprotect_* service names from rc.conf.local TEXT (the
# persisted `pkg_scripts=` boot order). Pure. Returns () if no pkg_scripts line
# or no ogmaprotect services (fail-safe -> caller reports 'unknown').
sub _parse_pkg_scripts_order {
    my ($text) = @_;
    return () unless defined $text;
    for my $line (split /\n/, $text) {
        next unless $line =~ /^\s*pkg_scripts\s*=\s*(.*)$/;
        my $val = $1;
        $val =~ s/#.*$//;               # strip a trailing comment
        $val =~ s/^\s*["']//; $val =~ s/["']\s*$//;   # strip surrounding quotes
        my @order;
        for my $tok (split /\s+/, $val) {
            push @order, $1 if $tok =~ /^ogmaprotect_([a-z]+)$/;
        }
        return @order;                  # first pkg_scripts line wins
    }
    return ();
}

# Verdict a persisted order against the dependency pairs. Pure. Checks ONLY
# pairs where BOTH members are present in the order (a partial install that
# enables only a subset is never flagged — fail-safe); an empty/undef order is
# 'unknown' (never a false alarm). A prerequisite that appears AFTER its
# dependent is 'wrong' and the detail names the offending pair.
sub _bootorder_verdict {
    my ($order, $deps) = @_;
    return { state => 'unknown', detail => 'no persisted boot order' }
        unless ref $order eq 'ARRAY' && @$order;
    my %pos;
    $pos{$order->[$_]} = $_ for 0 .. $#$order;
    for my $pair (@$deps) {
        my ($dep, $pre) = @$pair;
        next unless defined $pos{$dep} && defined $pos{$pre};
        if ($pos{$dep} < $pos{$pre}) {
            return { state => 'wrong',
                detail => "$dep enabled before $pre "
                    . "(management plane dies at next reboot)" };
        }
    }
    return { state => 'ok', detail => undef };
}

# Read the persisted order from /etc/rc.conf.local and verdict it. Fail-soft:
# an unreadable file yields 'unknown' (advisory), never an error.
sub _bootorder_status {
    my $text;
    if (open my $fh, '<', '/etc/rc.conf.local') {
        local $/;
        $text = <$fh>;
        close $fh;
    }
    my @order = _parse_pkg_scripts_order($text);
    return _bootorder_verdict(\@order, \@BOOT_DEPS);
}

# ---- 10.C6 S7b: drift-domain manifest helpers (M11/M13d) --------------------
# NOTE: the three $OGMA_DRIFT_MANIFEST_* constants these helpers read are
# declared ABOVE the `status` dispatch (next to @STATUS_DAEMONS), NOT here.
# `status` exits at `exit do_status()` before ANY file-scope initializer below
# it runs, so a `my $X = ...` placed here would still be undef inside do_status /
# _load_drift_manifest (the declaration-ordering bug this file fixes: $..._DEFAULT
# undef => "drift manifest unreadable ()", $..._MAX_VER undef => a bogus
# "schema_version newer than this tool (max )"). Do not move them back down.

# Emit the machine/human cannot-run envelope (the non-root-guard shape) + return
# 2. $json_out is passed in (not the file-scope global) so this sub is part of
# the self-contained helper block the t/ extracts + evals under `use strict`.
sub _status_cannot_run {
    my ($json_out, $msg) = @_;
    if ($json_out) {
        print JSON::PP->new->pretty->canonical->encode({
            ok => JSON::PP::false, error => $msg });
    } else {
        print STDERR "ogmaprotectctl status: $msg\n";
    }
    return 2;
}

# Load + FAIL-CLOSED validate the manifest. $live is a hashref { daemon_name=>1 }
# of the @STATUS_DAEMONS liveness daemons. Returns a hashref
#   { by_daemon => { name => [ {op,domain,socket,role,kind}, ... ] },
#     order => [daemon names, first-seen], iface_kinds => [...], count => N }
# on success, or an ERROR STRING on ANY failure (the caller exits 2 BEFORE
# $healthy). A silently-empty/truncated source must NEVER reach a clean walk.
sub _load_drift_manifest {
    my ($live) = @_;
    # The path is read from the env on EVERY call (default under /etc), so a test
    # can point it at a fixture; the installed CLI uses the default.
    my $path = $ENV{OGMA_DRIFT_MANIFEST} // $OGMA_DRIFT_MANIFEST_DEFAULT;
    my $text;
    {
        open my $fh, '<', $path
            or return "drift manifest unreadable ($path): $!";
        local $/;
        $text = <$fh>;
        close $fh;
    }
    return "drift manifest empty ($path)"
        if !defined $text || $text !~ /\S/;
    # Trap the decode: a corrupt/truncated file exits 2 (cannot-run), not 255.
    my $data = eval { JSON::PP->new->decode($text) };
    return "drift manifest is not valid JSON ($path)"
        if $@ || ref $data ne 'HASH';
    # schema_version gate BEFORE any row is consumed.
    my $ver = $data->{schema_version};
    return "drift manifest schema_version missing or non-integer"
        unless defined $ver && !ref $ver && $ver =~ /\A\d+\z/;
    return "drift manifest schema_version $ver newer than this tool "
        . "(max $OGMA_DRIFT_MANIFEST_MAX_VER) - upgrade ogmaprotectctl"
        if $ver > $OGMA_DRIFT_MANIFEST_MAX_VER;
    my $domains = $data->{domains};
    return "drift manifest has no non-empty 'domains' array"
        unless ref $domains eq 'ARRAY' && @$domains;
    my $ikinds = $data->{iface_kinds};
    return "drift manifest 'iface_kinds' is not an array"
        unless ref $ikinds eq 'ARRAY';
    my (%by_daemon, @order, %present, %iface_row_kinds);
    for my $row (@$domains) {
        return "drift manifest has a malformed domain row"
            unless ref $row eq 'HASH';
        for my $f (qw(daemon socket op domain role)) {
            my $v = $row->{$f};
            return "drift manifest row field '$f' missing or blank"
                unless defined $v && !ref $v && $v ne '';
        }
        return "drift manifest row role '$row->{role}' unknown"
            unless $row->{role} eq 'DOMAIN' || $row->{role} eq 'IFACE';
        # 10.C6 S7b fail-closed (F1): the per-interface walk vocabulary is the
        # IFACE rows' kinds. Require each IFACE row's kind non-blank + collect it,
        # so an emptied/short top-level iface_kinds cannot pass while the
        # (required-set-gated) IFACE rows stay intact and silently skip every
        # per-interface HA/VPN drift probe (a false-clean the hardcoded qw() list
        # could never produce).
        if ($row->{role} eq 'IFACE') {
            return "drift manifest IFACE row op '$row->{op}' has no kind"
                unless defined $row->{kind} && !ref $row->{kind}
                    && $row->{kind} ne '';
            $iface_row_kinds{ $row->{kind} } = 1;
        }
        # A manifest daemon with no liveness entry cannot reuse $is_up -> fail
        # closed rather than silently skip its drift.
        return "drift manifest daemon '$row->{daemon}' has no liveness entry"
            unless $live->{ $row->{daemon} };
        push @order, $row->{daemon} unless exists $by_daemon{ $row->{daemon} };
        push @{ $by_daemon{ $row->{daemon} } }, {
            op => $row->{op}, domain => $row->{domain},
            socket => $row->{socket}, role => $row->{role},
            kind => (defined $row->{kind} && !ref $row->{kind}) ? $row->{kind} : undef,
        };
        $present{ "$row->{daemon}:$row->{op}:$row->{domain}" } = 1;
    }
    # 10.C6 S7b fail-closed (F1): the top-level iface_kinds (the array the
    # do_status per-interface loop iterates) MUST equal the IFACE-role rows above,
    # which ARE covered by the required-set / three-way census gate. Set-equality
    # both ways => an empty/short/desynced iface_kinds fails closed instead of
    # skipping per-interface drift while status reads healthy. Unconditional (does
    # not depend on the required-set stamp).
    my %declared_ikinds = map { $_ => 1 } @$ikinds;
    for my $k (sort keys %iface_row_kinds) {
        return "drift manifest iface_kinds is missing IFACE kind '$k' (truncated?)"
            unless $declared_ikinds{$k};
    }
    for my $k (sort keys %declared_ikinds) {
        return "drift manifest iface_kinds has kind '$k' with no IFACE row"
            unless $iface_row_kinds{$k};
    }
    # The build-stamped required set MUST be a subset of the manifest (closes the
    # "short census walks fewer domains -> false clean" hole). Three states: the
    # un-substituted repo/test copy (still has '@') no-ops; a substituted-but-EMPTY
    # value means scripts/drift_required.txt was empty/stale at build time and the
    # subset gate would pass VACUOUSLY -> fail closed; else require the subset.
    if ($OGMA_DRIFT_REQUIRED =~ /\@/) {
        # placeholder (repo/test copy): the t/ + check-drift-registry pin the
        # census independently.
    } elsif ($OGMA_DRIFT_REQUIRED !~ /\S/) {
        return "drift required-set empty - ogmaprotectctl mis-stamped (rebuild)";
    } else {
        for my $req (split ' ', $OGMA_DRIFT_REQUIRED) {
            return "drift manifest is missing required domain '$req' (truncated?)"
                unless $present{$req};
        }
    }
    return {
        by_daemon => \%by_daemon, order => \@order,
        iface_kinds => $ikinds, count => scalar(@$domains),
    };
}

sub do_status {
    my $to = defined $timeout ? $timeout : 3;
    $to = 1  if $to < 1;
    $to = 60 if $to > 60;
    my $skip_drift = ($no_drift || $sockets_only) ? 1 : 0;
    my $am_root = ($> == 0);   # effective uid; the daemon peer ACL is uid==0

    my (@daemons, @drift, %versions);
    my ($up, $total) = (0, 0);
    my $net_resp;   # the netd get_net reply, reused to enumerate HA/VPN ifaces
    my $authd_up = 0;  # gates the L8-08 config-integrity pass (authd hosts it)
    my $sysd_up  = 0;  # gates the get_os_release (OS-release) probe (10.E3.2)
    my %live;       # daemon name -> { up => 0|1 } (the drift pass reuse)

    # 10.C6 S7b (M11): load the drift manifest FAIL-CLOSED, BEFORE the walk and
    # $healthy — a missing/empty/corrupt/short/unjoined manifest exits 2
    # (cannot-run) LOUD rather than walking 0 domains and reading exit-0 healthy.
    # Skipped only when drift itself is skipped (--no-drift / --sockets-only).
    my $manifest;
    unless ($skip_drift) {
        my %live_names = map { $_->[0] => 1 } @STATUS_DAEMONS;
        $manifest = _load_drift_manifest(\%live_names);
        return _status_cannot_run($json_out, $manifest)
            unless ref $manifest eq 'HASH';
    }

    # Liveness pass: ping every daemon socket once (incl. the liveness-only
    # daemons that report no drift domain). $total/$up/%versions/@daemons + the
    # authd/sysd gates key off THIS pass; the drift pass reuses %live by NAME.
    for my $d (@STATUS_DAEMONS) {
        my ($name, $sock) = @$d;
        $total++;
        my ($resp, $err) =
            _probe($sock, { op => 'ping', actor => 'console' }, $to);
        # A daemon is "up" if it returned a version — true even for an OLD daemon
        # that predates `ping` (it answers {ok:false,error:"unknown op",version}).
        my $is_up = ($resp && defined $resp->{version}) ? 1 : 0;
        my $ver = $is_up ? $resp->{version} : undef;
        $up++ if $is_up;
        $authd_up = $is_up if $name eq 'authd';
        $sysd_up  = $is_up if $name eq 'sysd';
        $versions{$ver} = 1 if defined $ver;
        push @daemons, {
            name    => $name,
            socket  => $sock,
            up      => ($is_up ? JSON::PP::true : JSON::PP::false),
            version => $ver,
            error   => ($is_up ? undef : ($err // 'down')),
        };
        $live{$name} = { up => $is_up, sock => $sock };
    }

    # Drift pass (M13d/e): iterate the MANIFEST grouped-by-daemon (one already-
    # pinged socket, 1..N DOMAIN drift ops), reusing the daemon's $is_up by NAME.
    # A DOWN daemon emits one 'unknown' row per drift op (kept aligned with
    # $domains_total below). cert's 2nd sysd domain + alerts' 2nd alertd domain
    # are plain manifest rows here, not loop special-cases. IFACE-role rows are
    # NOT probed here — they are per-interface (enumerated from get_net below);
    # the manifest's iface_kinds drives that loop.
    unless ($skip_drift) {
        for my $daemon (@{ $manifest->{order} }) {
            my $up_daemon = $live{$daemon}{up};  # exists (fail-closed load guarantees it)
            for my $row (@{ $manifest->{by_daemon}{$daemon} }) {
                next unless $row->{role} eq 'DOMAIN';
                my ($op, $dom) = ($row->{op}, $row->{domain});
                if (!$up_daemon) {
                    push @drift, { domain => $dom, daemon => $daemon,
                        state => 'unknown', keys => [], detail => 'daemon down' };
                    next;
                }
                # Probe the SAME socket the liveness pass pinged (build-checked
                # equal to the manifest's socket by the t/ successor) — consistent
                # with $is_up and honouring the fixed $*_path literals.
                my ($dr, $derr) =
                    _probe($live{$daemon}{sock},
                    { op => $op, actor => 'console' }, $to);
                $net_resp = $dr if $dom eq 'interfaces'; # reused below (per-iface)
                if ($dr && _is_true($dr->{ok})) {
                    my ($state, $keys, $detail) = _domain_drift($dr);
                    push @drift, { domain => $dom, daemon => $daemon,
                        state => $state, keys => $keys, detail => $detail };
                } else {
                    my $msg = ($dr && $dr->{error}) ? $dr->{error}
                        : ($derr // 'read failed');
                    push @drift, { domain => $dom, daemon => $daemon,
                        state => 'error', keys => [], detail => $msg };
                }
            }
        }
    }

    # ---- per-interface HA/VPN drift (9.I-02b) ----------------------------
    # Enumerate the carp/pfsync/wireguard/tunnel/pppoe interfaces from the
    # get_net reply we already fetched, then probe the matching per-iface op on
    # the netd socket and read its drift object. Gated by $skip_drift like the
    # domain walk; runs only when netd answered get_net (so the single-threaded
    # netd is known responsive). Each _probe is independently alarm()-bounded and
    # fail-soft, so one wedged/absent iface read degrades only that row.
    my @iface_drift;
    my $iface_drift_count = 0;
    my $net_ok = (defined $net_resp && _is_true($net_resp->{ok})) ? 1 : 0;
    if (!$skip_drift && $net_ok) {
        my $buckets = _bucket_ifaces_by_kind($net_resp);
        # 10.C6 S7b (M4a): the per-iface kind vocabulary is the manifest's IFACE
        # rows (generated from ops.c drift_kind), not a hand-kept qw() list. The
        # _bucket_ifaces_by_kind filter stays a defensive allowlist, build-pinned
        # to these kinds by the t/ successor.
        for my $kind (sort @{ $manifest->{iface_kinds} }) {
            my @names = sort @{ $buckets->{$kind} || [] };
            # pfsync(4) is a kernel singleton: get_pfsync ignores the interface
            # arg and scans canonical for the one pfsync iface — probe once.
            @names = @names ? ($names[0]) : () if $kind eq 'pfsync';
            for my $if (@names) {
                my ($resp) = _probe($netd_path,
                    { op => "get_$kind", interface => $if, actor => 'console' },
                    $to);
                my ($state, $verdict, $detail) = _iface_verdict($kind, $resp);
                push @iface_drift, { iface => $if, kind => $kind,
                    state => $state, verdict => $verdict, detail => $detail };
                # Fail-closed: both a confirmed mismatch (drift) and an
                # unreadable live state (error) on a CONFIGURED HA/VPN iface are
                # actionable => degraded. 'limited'/'unknown' do NOT count.
                # 10.C6 S6d (M1/D13): the domain walk now ALSO degrades on a
                # domain 'error' (via $domains_error), so this iface-walk posture
                # is no longer a divergence — both surfaces fail closed alike.
                $iface_drift_count++ if $state eq 'drift' || $state eq 'error';
            }
        }
    }
    my $iface_drift_total = scalar @iface_drift;

    # ---- config-dir manifest integrity (L8-08) ---------------------------
    # Root-console recompute of each canonical fragment's SHA-256 vs the
    # persisted OGMA_CONFIG_DIR/manifest.yaml, so latent bit-rot is visible on
    # demand. It lives behind authd's root-only get_config_integrity op (authd is
    # the one socket daemon that unveils the config dir, read-only). Gated by
    # $skip_drift like the drift passes, and only when authd answered. Fail-soft:
    # any probe failure degrades only this section to 'unavailable' (advisory) —
    # a wedged/absent authd already shows as its own DOWN daemon row.
    my $integ;                        # the authd-emitted integrity object
    my ($integ_state, $integ_degraded, $integ_detail) = ('skipped', 0, undef);
    if (!$skip_drift && $authd_up) {
        my ($iresp) = _probe($sock_path,
            { op => 'get_config_integrity', actor => 'console' }, $to);
        $integ = (ref $iresp eq 'HASH') ? $iresp->{integrity} : undef;
        ($integ_state, $integ_degraded, $integ_detail) =
            _integrity_verdict($iresp);
    }

    # ---- restore transaction status (10.A4) ------------------------------
    # authd's crash-safe-restore journal state: an unresolved restore
    # transaction (reverse or forward recovery pending) is exactly the
    # half-restore condition L8-02 describes, so it degrades the box until
    # recovery resolves it. Same gating + fail-soft shape as the L8-08 pass.
    my $restore;                      # the authd-emitted restore_status object
    my ($restore_state, $restore_degraded, $restore_detail) =
        ('skipped', 0, undef);
    if (!$skip_drift && $authd_up) {
        my ($rresp) = _probe($sock_path,
            { op => 'get_restore_status', actor => 'console' }, $to);
        $restore = (ref $rresp eq 'HASH') ? $rresp->{restore_status} : undef;
        ($restore_state, $restore_degraded, $restore_detail) =
            _restore_verdict($rresp);
    }

    # ---- config-fragment schema state (10.E2 S2) -------------------------
    # Each canonical fragment's on-disk `version:` against this build's ceiling,
    # from authd's root-only get_schema_state (same unveil, same sessionless
    # arm, same fail-soft posture as the L8-08 pass above). A fragment written
    # by a NEWER build than this one is the MECH-DEGRADE condition and degrades
    # the box; a fragment merely BELOW the ceiling is the ratified P-lazy steady
    # state (D9) and is advisory — degrading there would read every upgraded box
    # as degraded.
    my $sch;                          # the authd-emitted schema_state object
    my ($schema_state, $schema_degraded, $schema_detail) =
        ('skipped', 0, undef);
    if (!$skip_drift && $authd_up) {
        my ($sresp) = _probe($sock_path,
            { op => 'get_schema_state', actor => 'console' }, $to);
        $sch = (ref $sresp eq 'HASH') ? $sresp->{schema_state} : undef;
        ($schema_state, $schema_degraded, $schema_detail) =
            _schema_verdict($sresp);
    }

    # ---- OS-release qualification (10.A6, advisory) ----------------------
    # Ask sysd whether the running OpenBSD release is one OgmaProtect is
    # qualified on. PURELY ADVISORY: it never feeds $healthy or the exit code
    # (an unqualified/unknown release is a heads-up, not a fault — the guard
    # never refuses to start). Fail-soft: a down/old sysd yields undef and the
    # line is simply omitted. Not gated by $skip_drift so it still shows under
    # --no-drift/--sockets-only.
    #
    # 10.E3.2 (WA-R3 #321): the probe is get_os_release, the uname-only read —
    # NEVER get_system_version, whose handler execs `syspatch -c` (a 30 s root
    # network fetch) inside single-threaded sysd; coupling a cron-cadence
    # liveness command to the mirror wedged sysd for up to 30 s per run. An
    # old sysd (no such op) yields undef and the line is omitted, same as down.
    #
    # 10.E6.2 rides the same probe: the projected OS-EOL state + date (both
    # compiled-table lookups, so they cost the repointed probe nothing). Same
    # advisory contract; a pre-E6.2 response simply leaves both undef.
    my ($osrel, $osqual, $oseol, $oseoldate);
    if ($sysd_up) {
        my ($vresp) = _probe($sysd_path,
            { op => 'get_os_release', actor => 'console' }, $to);
        my $sv = (ref $vresp eq 'HASH') ? $vresp->{os_release} : undef;
        if (ref $sv eq 'HASH') {
            $osrel     = $sv->{release};
            $osqual    = $sv->{qualified};
            $oseol     = $sv->{eol};
            $oseoldate = $sv->{eol_date};
        }
    }

    # ---- the installed build release vs the running one (10.E6 VD-E6-22) ----
    # Read by THIS process from the in-package RELEASE-INFO -- no daemon in the
    # loop, because on the failure this names none of them can start. Advisory:
    # never feeds $healthy. The pure classifier is _release_match (unit-tested).
    my $relinfo_text;
    if (open my $rfh, '<', $OGMA_RELEASE_INFO) {
        local $/; $relinfo_text = <$rfh>; close $rfh;
    }
    my $relmatch = _release_match($relinfo_text, _running_release());

    # ---- the on-box update signal (10.E6 S3, advisory) ---------------------
    # get_update_status is a ZERO-exec read of sysd's persisted verdict record
    # + the compiled build stamp — never a fetch (P9: the probe must stay
    # liveness-cadence cheap, exactly like get_os_release above). PURELY
    # ADVISORY: never feeds $healthy or the exit code. Fail-soft: a down/old
    # sysd (no such op) yields undef and the lines are omitted. The four D10c
    # states print DISTINCTLY and none reads as "up to date" except
    # verified-fresh + current.
    my ($updstate, $updcmp, $updadv, $updcur, $updenabled, $updlastok,
        $updattempt, $updoutcome, $upddetail, $buildrev, $builddate, $buildage,
        $updedition);
    # 10.E10 S1 (§3.5 M-status, §7 D-12): the `staged` block -- WHERE a staged
    # update is visible (never `windows`, §2 P2). Read off the SAME probe, never
    # a second one; undef on a pre-S1 sysd (the Staged: line is then omitted).
    my ($stgstate, $stgver, $stgbytes, $stgexpected, $stgupdated, $stgcode,
        $stgerr, $stgverified);
    if ($sysd_up) {
        my ($uresp) = _probe($sysd_path,
            { op => 'get_update_status', actor => 'console' }, $to);
        my $su = (ref $uresp eq 'HASH') ? $uresp->{update_status} : undef;
        if (ref $su eq 'HASH') {
            my $sg = $su->{staged};
            if (ref $sg eq 'HASH') {
                $stgstate    = $sg->{state};
                $stgver      = $sg->{version};
                $stgbytes    = $sg->{bytes};
                $stgexpected = $sg->{expected};
                $stgupdated  = $sg->{updated_iso};
                $stgcode     = $sg->{error_code};
                $stgerr      = $sg->{error};
                $stgverified = $sg->{verified} ? 1 : 0;
            }
            # 10.E6 AP1 (D-A9 SK-marker): the SKU token, image|software; undef
            # on a pre-AP1 sysd (the line below is then omitted, never guessed).
            $updedition = $su->{edition};
            $updstate   = $su->{state};
            $updcmp     = $su->{cmp};
            $updadv     = $su->{advisory} ? 1 : 0;
            $updcur     = $su->{current};
            $updenabled = $su->{enabled} ? 1 : 0;
            $updlastok  = $su->{last_ok_iso};
            $updattempt = $su->{last_attempt_iso};
            $updoutcome = $su->{last_attempt_outcome};
            $upddetail  = $su->{last_attempt_detail};
            $buildrev   = $su->{build_rev};
            $builddate  = $su->{build_date};
            $buildage   = $su->{build_age_days};
        }
    }

    # ---- the boot fsck auto-repair posture (10.E9 S1, advisory) -----------
    # get_fsck_setting is a zero-exec read of sysd's view: the saved intent,
    # the rendered opt-out marker, the live /etc/rc sentinel (the patch-
    # presence drift domain, contract §2.1(b)), the tmpfs boot sentinel and
    # the last repair record. ADVISORY: never feeds $healthy. Fail-soft: a
    # down/old sysd (no such op) yields undef and the line is omitted. The
    # two loud arms are `[rc-patch NOT applied]` (the toggle cannot take
    # effect -- §4 S1 says this must never be silent) and `[FSCK-REPAIRED
    # <ts>]` (a repair ran THIS boot: go read lost+found).
    my ($fsckauto, $fsckpatch, $fsckrepaired, $fsckdrift, $fscklast, $fscklf);
    if ($sysd_up) {
        my ($fresp) = _probe($sysd_path,
            { op => 'get_fsck_setting', actor => 'console' }, $to);
        my $sf = (ref $fresp eq 'HASH') ? $fresp->{fsck_setting} : undef;
        if (ref $sf eq 'HASH') {
            $fsckauto     = _is_true($sf->{autorepair}) ? 1 : 0;
            $fsckpatch    = $sf->{rc_patch};
            $fsckrepaired = _is_true($sf->{repaired_this_boot}) ? 1 : 0;
            $fsckdrift    = _is_true($sf->{marker_mismatch}) ? 1 : 0;
            my $lr = (ref $sf->{last_repair} eq 'HASH') ? $sf->{last_repair} : {};
            $fscklast = _is_true($lr->{present}) ? ($lr->{iso} // '?') : undef;
            $fscklf   = _is_true($lr->{lost_found}) ? 1 : 0;
        }
    }

    # ---- auth database liveness (Phase 10.A2) ----------------------------
    # authd's db child can die (corrupt users.db, OOM) while the sessionless
    # ping still answers ok — the L1-06 liveness lie. db_status reports the real
    # db health and, on the root console, the DR-visibility fields (a rebuilt
    # box whose restore did NOT carry users.db reads auth_empty/auth_restored:
    # false). A dead db is a degraded box (auth unavailable) even though authd's
    # process is up. Fail-soft: an unreachable db_status leaves it unknown.
    my ($db_healthy, $db_degraded, $auth_empty, $auth_restored) =
        (undef, undef, undef, undef);
    if ($authd_up) {
        my ($dresp) = _probe($sock_path,
            { op => 'db_status', actor => 'console' }, $to);
        if (ref $dresp eq 'HASH' && defined $dresp->{db_healthy}) {
            $db_healthy  = _is_true($dresp->{db_healthy})  ? 1 : 0;
            $db_degraded = _is_true($dresp->{db_degraded}) ? 1 : 0;
            $auth_empty  = _is_true($dresp->{auth_empty})  ? 1 : 0
                if defined $dresp->{auth_empty};
            # auth_restored:false is the loud "credentials not restored" flag.
            $auth_restored = _is_true($dresp->{auth_restored}) ? 1 : 0
                if defined $dresp->{auth_restored};
        }
    }
    my $db_bad = (defined $db_healthy && !$db_healthy) ? 1 : 0;

    # 10.C6 S7b (M8c, closes VD-C6-S6d-8): the version-skew check now includes the
    # CLI's OWN compiled version, not just daemon-vs-daemon — a ctl that is out of
    # step with the daemons it reads can misparse a newer drift shape, so a
    # ctl↔daemon skew degrades health (soft: exit 1 / healthy:false, never a hard
    # cannot-run — a partial-install window must stay runnable). Added only when
    # the 0.5.5 seam was substituted (the installed CLI); the raw repo
    # copy keeps the literal placeholder and is never run against live daemons.
    my $ctl_version = '0.5.5';
    $versions{$ctl_version} = 1 if $ctl_version !~ /\@/;
    my $version_consistent = (scalar(keys %versions) <= 1) ? 1 : 0;
    my $common_version = (keys %versions)[0];
    # 10.C9 / L1-22: advisory persisted boot-order verdict (never feeds $healthy
    # or the exit code — a wrong order is a NEXT-reboot risk, not a live fault).
    my $bootorder = _bootorder_status();
    # 10.C6 S7b: the DOMAIN census is the manifest's DOMAIN rows (the runtime
    # source now, M13d) — one per per-daemon domain drift op (sysd: identity+cert,
    # alertd: remotelog+alerts). IFACE rows are counted separately (iface_drift).
    my $domains_total = 0;
    if (!$skip_drift) {
        for my $daemon (@{ $manifest->{order} }) {
            $domains_total += scalar
                grep { $_->{role} eq 'DOMAIN' }
                @{ $manifest->{by_daemon}{$daemon} };
        }
    }
    my $domains_drifted = scalar grep { $_->{state} eq 'drift' } @drift;
    # 10.C6 S6d (M1/D13): a domain that could not determine its live state (a
    # fail-closed 'error' from _domain_drift) degrades health — matching the iface
    # walk, whose 'error'-degrades divergence this closes. BEHAVIOR CHANGE:
    # `ogmaprotectctl status` now exits 1 / healthy:false on a domain read error.
    my $domains_error = scalar grep { $_->{state} eq 'error' } @drift;

    # Fatal, cannot-run: a NON-root caller that reached no daemon almost
    # certainly lacks socket access (the peer ACL is uid==0 — a non-root peer is
    # accepted then dropped, or refused outright). Give the actionable hint and
    # exit 2, rather than a confusing "every daemon is down". Root with 0 up is a
    # genuine outage and flows to the normal degraded (exit 1) path below.
    if (!$am_root && $up == 0) {
        if ($json_out) {
            print JSON::PP->new->pretty->canonical->encode({
                ok => JSON::PP::false,
                error => 'no daemon socket reachable - run ogmaprotectctl status as root',
            });
        } else {
            print STDERR "ogmaprotectctl status: no daemon socket reachable - "
                . "run as root\n";
        }
        return 2;
    }

    my $healthy = _health_verdict(
        up                 => $up,
        total              => $total,
        domains_drifted    => $domains_drifted,
        domains_error      => $domains_error,
        iface_drift_count  => $iface_drift_count,
        integ_degraded     => $integ_degraded,
        schema_degraded    => $schema_degraded,
        restore_degraded   => $restore_degraded,
        version_consistent => $version_consistent,
        db_bad             => $db_bad,
    );

    if ($json_out) {
        print JSON::PP->new->pretty->canonical->encode({
            ok           => JSON::PP::true,
            generated_at => _now_utc(),
            hostname     => _hostname(),
            daemons      => \@daemons,
            drift        => \@drift,
            iface_drift  => \@iface_drift,
            integrity    => $integ,   # authd get_config_integrity object (or null)
            schema_state => $sch,     # authd get_schema_state object (or null)
            restore_status => $restore, # authd get_restore_status object (or null)
            summary      => {
                daemons_up         => $up,
                daemons_total      => $total,
                domains_drifted    => $domains_drifted,
                domains_error      => $domains_error,
                domains_total      => $domains_total,
                iface_drift_count  => $iface_drift_count,
                iface_drift_total  => $iface_drift_total,
                integrity_state    => $integ_state,
                integrity_degraded =>
                    ($integ_degraded ? JSON::PP::true : JSON::PP::false),
                schema_state_state    => $schema_state,
                schema_state_degraded =>
                    ($schema_degraded ? JSON::PP::true : JSON::PP::false),
                restore_state      => $restore_state,
                restore_degraded   =>
                    ($restore_degraded ? JSON::PP::true : JSON::PP::false),
                # 10.E4 S3 (§7 D5): the open confirm-window census, so a
                # monitoring consumer sees the one `idle` state that still
                # refuses a restore. Empty string when none, never absent --
                # a consumer must be able to tell "no windows" from "this
                # authd predates S3".
                restore_open_windows =>
                    (ref $restore eq 'HASH' && defined $restore->{open_windows}
                     && !ref $restore->{open_windows})
                    ? $restore->{open_windows} : '',
                version_consistent =>
                    ($version_consistent ? JSON::PP::true : JSON::PP::false),
                version => $common_version,
                db_healthy => (defined $db_healthy
                    ? ($db_healthy ? JSON::PP::true : JSON::PP::false) : undef),
                db_degraded => (defined $db_degraded
                    ? ($db_degraded ? JSON::PP::true : JSON::PP::false) : undef),
                auth_empty => (defined $auth_empty
                    ? ($auth_empty ? JSON::PP::true : JSON::PP::false) : undef),
                auth_restored => (defined $auth_restored
                    ? ($auth_restored ? JSON::PP::true : JSON::PP::false) : undef),
                healthy => ($healthy ? JSON::PP::true : JSON::PP::false),
                # 10.A6 advisory OS-release facts (NOT part of $healthy).
                os_release          => $osrel,
                osrelease_qualified => $osqual,
                # 10.E6 VD-E6-22: the installed package's build release vs the
                # running kernel (installed state, read by THIS process --
                # available with every daemon down). match|mismatch|unknown.
                # Advisory -- never feeds $healthy.
                release_match       => $relmatch->{state},
                built_for_openbsd   => ($relmatch->{built_for} ne ''
                    ? $relmatch->{built_for} : undef),
                # 10.E6.2 advisory projected-EOL facts (NOT part of $healthy;
                # undef => null on a pre-E6.2 sysd, matching the pair above).
                osrelease_eol       => $oseol,
                osrelease_eol_date  => $oseoldate,
                # 10.C9 advisory boot-order verdict (NOT part of $healthy).
                boot_order          => $bootorder->{state},
                boot_order_detail   => $bootorder->{detail},
                # 10.E6 S3 advisory update-signal facts (NOT part of $healthy;
                # undef => null on a pre-S3 sysd). The four D10c state tokens
                # ride verbatim; cmp is current|update|ahead|unknown.
                update_state        => $updstate,
                update_cmp          => $updcmp,
                update_advisory     => (defined $updstate
                    ? ($updadv ? JSON::PP::true : JSON::PP::false) : undef),
                update_current      => $updcur,
                update_check_enabled => (defined $updstate
                    ? ($updenabled ? JSON::PP::true : JSON::PP::false) : undef),
                update_last_ok      => $updlastok,
                build_rev           => $buildrev,
                build_date          => $builddate,
                build_age_days      => $buildage,
                # 10.E6 AP1 (D-A9): the SKU token verbatim (image|software;
                # null on a pre-AP1 sysd). Advisory — never feeds $healthy.
                edition             => $updedition,
                # 10.E10 S1 (D-12): the staged block's facts verbatim (the
                # seven state tokens; null on a pre-S1 sysd). Advisory --
                # never feeds $healthy: a staged update is a choice, not a
                # fault.
                update_staged_state    => $stgstate,
                update_staged_version  => $stgver,
                update_staged_bytes    => $stgbytes,
                update_staged_expected => $stgexpected,
                update_staged_updated  => $stgupdated,
                update_staged_verified => (defined $stgstate
                    ? ($stgverified ? JSON::PP::true : JSON::PP::false) : undef),
                update_staged_error_code => $stgcode,
                update_staged_error    => $stgerr,
                # 10.E9 S1: the boot fsck posture (null on a pre-S1 sysd).
                # Advisory — never feeds $healthy.
                fsck_autorepair     => (defined $fsckauto
                    ? ($fsckauto ? JSON::PP::true : JSON::PP::false) : undef),
                fsck_rc_patch       => $fsckpatch,
                fsck_repaired_this_boot => (defined $fsckrepaired
                    ? ($fsckrepaired ? JSON::PP::true : JSON::PP::false) : undef),
                fsck_marker_drift   => (defined $fsckdrift
                    ? ($fsckdrift ? JSON::PP::true : JSON::PP::false) : undef),
                fsck_last_repair    => $fscklast,
            },
        });
        return $healthy ? 0 : 1;
    }

    printf "OgmaProtect status  -  %s  -  %s\n\n", _hostname(), _now_utc();
    printf "Daemons (%d/%d up):\n", $up, $total;
    for my $x (@daemons) {
        if ($x->{up}) {
            my $extra = '';
            if ($x->{name} eq 'authd' && defined $db_healthy) {
                $extra = $db_healthy ? '  (db HEALTHY)' : '  (db DEAD)';
            }
            printf "  %-9s up      %s%s\n", $x->{name},
                ($x->{version} // '?'), $extra;
        } else {
            printf "  %-9s DOWN    %s\n", $x->{name}, ($x->{error} // 'down');
        }
    }
    print "  gwmond    socketless (not probed)\n";

    # 10.A6 advisory OS-release line (omitted if sysd is unreachable). 'qualified'
    # prints plain; 'unqualified' gets [UNQUALIFIED]; 'unknown' (empty/failed read)
    # gets [unknown] and never the scary tag.
    # 10.E6.2 appends the projected-EOL segment to the same line: 'eol' gets the
    # loud bracket tag, 'approaching' the calm parenthesis, and supported/unknown
    # (incl. a pre-E6.2 sysd) print nothing — the copy always says "projected"
    # because the date is a vendor projection, never an observed upstream event.
    if (defined $osqual) {
        my $tag = $osqual eq 'unqualified' ? ' [UNQUALIFIED]'
                : $osqual eq 'unknown'     ? ' [unknown]'
                :                            '';
        my $rel = (defined $osrel && $osrel ne '') ? $osrel : 'unknown';
        my $eoltag = '';
        if (defined $oseol && defined $oseoldate && $oseoldate ne '') {
            $eoltag = $oseol eq 'eol'         ? " [past projected EOL $oseoldate]"
                    : $oseol eq 'approaching' ? " (EOL projected $oseoldate)"
                    :                           '';
        }
        printf "  OS release: %s%s%s\n", $rel, $tag, $eoltag;
    }

    # 10.E6 VD-E6-22: the build-release line. Printed WHENEVER the installed
    # RELEASE-INFO could be read -- including with every daemon down, which is
    # exactly when it matters -- and omitted only when there is nothing to
    # compare (no file: a pre-0.5.3 install). Loud [BRACKETS] on a mismatch,
    # naming the consequence and the remedy; calm on a match; `unknown` never
    # reads as a match.
    if ($relmatch->{state} ne 'unknown' || $relmatch->{built_for} ne '') {
        my $bf = $relmatch->{built_for} ne '' ? $relmatch->{built_for} : 'unknown';
        my $run = _running_release() || 'unknown';
        my $rline = $relmatch->{state} eq 'match'
            ? "OpenBSD $bf (matches this box)"
            : $relmatch->{state} eq 'mismatch'
            ? "OpenBSD $bf  [RELEASE MISMATCH: this box runs OpenBSD $run - the daemons cannot load their shared libraries; install the package built for $run (INSTALL.md 3a)]"
            : "OpenBSD $bf  [unknown - cannot compare with this box]";
        printf "  Built for:  %s\n", $rline;
    }

    # 10.E6 S3 advisory build + update lines (omitted if sysd is unreachable or
    # predates the op). Same convention as the OS line: loud [BRACKETS] for the
    # actionable states, calm (parens) for advisory ones, nothing scary for
    # never/unknown. Never touches $healthy.
    if (defined $updstate) {
        my $bline = 'unknown';
        if (defined $builddate && $builddate ne '' && $builddate ne 'unknown') {
            $bline = $builddate;
            $bline .= (defined $buildage && $buildage =~ /^\d+$/)
                ? sprintf(' (%d days old%s)', $buildage,
                    $buildage >= 180 ? ' - check advisories' : '')
                : '';
        }
        printf "  Build:      %s, %s\n", ($buildrev // 'unknown'), $bline;
        my $uline;
        if ($updstate eq 'verified-fresh') {
            $uline = $updadv ? "[ADVISORY affects this version - upgrade to "
                               . ($updcur // '?') . "]"
                   : (($updcmp // '') eq 'update') ? "[update available: " . ($updcur // '?') . "]"
                   : (($updcmp // '') eq 'current') ? "current release (" . ($updcur // '?') . ")"
                   : (($updcmp // '') eq 'ahead')   ? "(ahead of the published release " . ($updcur // '?') . ")"
                   :                                 "(verified; version not comparable)";
        } elsif ($updstate eq 'verified-but-expired') {
            $uline = "[verified manifest EXPIRED - re-check]";
        } elsif ($updstate eq 'verification-failed') {
            $uline = "[VERIFICATION FAILED: " . ($updoutcome // '?') . "]";
        } else {
            $uline = "(never successfully checked)";
        }
        $uline .= defined $updlastok && $updlastok ne ''
            ? "  last verified $updlastok" : '';
        printf "  Update:     %s  daily check %s\n", $uline,
            $updenabled ? 'on' : 'off';
        if (defined $updoutcome && $updoutcome ne 'ok' && $updoutcome ne 'none'
            && defined $updattempt && $updattempt ne '') {
            printf "              last attempt %s: %s%s\n", $updattempt, $updoutcome,
                (defined $upddetail && $upddetail ne '') ? " - $upddetail" : '';
        }
        # 10.E6 AP1 (D-A9 SK-marker): the edition line — the SKU-aware remedy
        # for every "upgrade to" above. 'image' names the image-release remedy
        # and says what does NOT apply on a read-only root; 'software' prints
        # calm; any other token reads 'unknown' (fail closed, never a guessed
        # edition); a pre-AP1 sysd (undef) prints no line at all.
        if (defined $updedition) {
            my $eline = $updedition eq 'image'
                ? 'appliance image (read-only root; updates arrive as vendor-signed '
                  . 'image releases - pkg_add and sysupgrade do not apply)'
                : $updedition eq 'software' ? 'software (pkg_add channel)'
                :                              'unknown';
            printf "  Edition:    %s\n", $eline;
        }
        # 10.E10 S1 (§3.5, §7 D-12): the Staged: line, the [BRACKET]/(paren)
        # convention of the lines above -- loud for the states an operator
        # must act on (staged: apply it in the window; failed: read the
        # reason; applied-pending-reboot: the reboot is coming), calm for the
        # transient ones, and NO line at all when nothing is staged or on a
        # pre-S1 sysd (never a guessed state).
        if (defined $stgstate && $stgstate ne 'none') {
            my $sline;
            my $ver = (defined $stgver && $stgver ne '') ? $stgver : '?';
            my $pct = '';
            if (defined $stgexpected && $stgexpected =~ /^\d+$/ && $stgexpected > 0
                && defined $stgbytes && $stgbytes =~ /^\d+$/) {
                $pct = sprintf(' %d%%', int($stgbytes * 100 / $stgexpected));
            }
            if ($stgstate eq 'staged') {
                $sline = "[$ver STAGED - apply when the outage is allowed: "
                       . "OGMACTL_CONFIRM=1 ogmaprotectctl update apply]";
            } elsif ($stgstate eq 'downloading') {
                $sline = "($ver downloading$pct)";
            } elsif ($stgstate eq 'verifying') {
                $sline = "($ver verifying)";
            } elsif ($stgstate eq 'applied-pending-reboot') {
                $sline = "[$ver APPLIED - rebooting]";
            } elsif ($stgstate eq 'failed') {
                $sline = "[$ver FAILED"
                       . ((defined $stgcode && $stgcode ne '') ? " $stgcode" : '')
                       . ((defined $stgerr && $stgerr ne '') ? ": $stgerr" : '')
                       . "]";
            } elsif ($stgstate eq 'discarded') {
                $sline = "($ver discarded)";
            } else {
                $sline = "(unknown state)";
            }
            $sline .= "  $stgupdated" if defined $stgupdated && $stgupdated ne '';
            printf "  Staged:     %s\n", $sline;
        }
    }

    # 10.E9 S1: the boot fsck line. Loud [BRACKETS] for the two actionable
    # states (a repair ran this boot; the rc patch is not applied so the
    # setting is inert), calm otherwise; omitted on a pre-S1 sysd.
    if (defined $fsckauto) {
        my $fline = $fsckauto ? 'auto-repair on' : 'auto-repair OFF (manual)';
        $fline .= $fsckrepaired ? "  [FSCK-REPAIRED " . ($fscklast // '?') . "]"
                . ($fscklf ? " lost+found NON-EMPTY" : '')
              : (defined $fscklast ? "  last repair $fscklast" : '');
        $fline .= (($fsckpatch // '') eq 'applied') ? '  (rc patch applied)'
                : (($fsckpatch // '') eq 'absent')  ? '  [rc-patch NOT applied]'
                :                                     '  [rc-patch state unknown]';
        $fline .= '  [marker DRIFT]' if $fsckdrift;
        printf "  Boot fsck:  %s\n", $fline;
    }

    # 10.C9 / L1-22 advisory boot-order line. Only the actionable 'wrong' verdict
    # is surfaced loudly; 'ok'/'unknown' stay quiet (never a scary false alarm).
    if ($bootorder->{state} eq 'wrong') {
        printf "  Boot order: [WRONG] %s\n",
            ($bootorder->{detail} // 'reorder pkg_scripts');
    }

    # Phase 10.A2: auth database liveness + DR notice (root console only sees
    # auth_empty/auth_restored; a non-root caller gets db_healthy/db_degraded).
    if (defined $db_healthy) {
        print "\nAuth database:\n";
        printf "  users.db   %s\n", $db_healthy ? 'HEALTHY'
            : ($db_degraded ? 'DEGRADED (auth unavailable)' : 'DEAD');
        if (defined $auth_empty && $auth_empty) {
            # 10.E2 S4: was "(see docs/PHASE10-E4-DR-RUNBOOK.md)". docs/PHASE*.md
            # is export-ignored, so that file is in neither the dist tarball nor
            # an installed box -- a dead pointer at the moment of an empty-auth
            # emergency. Name the recovery action instead.
            print "  WARNING: zero admin accounts — console re-seed required "
                . "(re-run the provisioner, or add an admin over the root "
                . "control socket: ogmaprotectctl user add <name>)\n";
        }
        if (defined $auth_restored && !$auth_restored) {
            print "  WARNING: a backup was restored but credentials were NOT "
                . "(users.db is excluded from backups) — re-seed via console\n";
        }
    }

    unless ($skip_drift) {
        print "\nConfig drift:\n";
        for my $x (@drift) {
            if ($x->{state} eq 'clean') {
                printf "  %-11s clean\n", $x->{domain};
            } elsif ($x->{state} eq 'drift') {
                printf "  %-11s DRIFT   (%s)\n", $x->{domain},
                    join(', ', @{ $x->{keys} });
            } elsif ($x->{state} eq 'unknown') {
                printf "  %-11s unknown (%s)\n", $x->{domain},
                    ($x->{detail} // 'unknown');
            } else {
                printf "  %-11s error   (%s)\n", $x->{domain},
                    ($x->{detail} // 'error');
            }
        }
    }

    unless ($skip_drift) {
        print "\nInterface HA/VPN drift:\n";
        if (!$net_ok) {
            print "  (netd unavailable - interface drift not checked)\n";
        } elsif (!@iface_drift) {
            print "  none configured\n";
        } else {
            for my $x (@iface_drift) {
                my $head = sprintf("  %-9s %-10s", $x->{iface}, $x->{kind});
                my $st = $x->{state};
                if ($st eq 'clean') {
                    print "$head clean\n";
                } elsif ($st eq 'drift') {
                    printf "%s DRIFT    (%s)\n", $head, ($x->{detail} // 'drift');
                } elsif ($st eq 'limited') {
                    printf "%s limited  (%s)\n", $head,
                        ($x->{detail} // 'key check via web UI');
                } elsif ($st eq 'error') {
                    printf "%s error    (%s)\n", $head,
                        ($x->{detail} // 'could not read live interface state');
                } else {
                    printf "%s unknown  (%s)\n", $head, ($x->{detail} // 'unknown');
                }
            }
        }
    }

    unless ($skip_drift) {
        print "\nConfig integrity:\n";
        if (!$authd_up) {
            print "  (authd unavailable - integrity not checked)\n";
        } elsif ($integ_state eq 'unavailable') {
            printf "  unavailable (%s)\n", ($integ_detail // 'no data');
        } elsif ($integ_state eq 'absent') {
            print "  not generated (no manifest.yaml)\n";
        } elsif ($integ_state eq 'unsupported') {
            printf "  unsupported (format_version %s)\n",
                (ref $integ eq 'HASH' ? ($integ->{format_version} // '?') : '?');
        } elsif ($integ_state eq 'malformed') {
            print "  MALFORMED  (manifest corrupt - regenerate)\n";
        } else {
            my $sum = (ref $integ eq 'HASH' && ref $integ->{summary} eq 'HASH')
                ? $integ->{summary} : {};
            my $ga = (ref $integ eq 'HASH' && $integ->{generated_at})
                ? $integ->{generated_at} : '?';
            my $hdr = $integ_state eq 'clean' ? 'clean'
                    : $integ_state eq 'warn'  ? 'REFRESH NEEDED'
                    :                           'MISMATCH';
            printf "  %-14s (as of %s)\n", $hdr, $ga;
            printf "  %d fragments: %d match, %d mismatch, %d stale, "
                 . "%d missing, %d error\n",
                _int($sum->{total}), _int($sum->{match}), _int($sum->{mismatch}),
                _int($sum->{stale}), _int($sum->{missing}), _int($sum->{error});
            my $frags = (ref $integ eq 'HASH' && ref $integ->{fragments} eq 'ARRAY')
                ? $integ->{fragments} : [];
            # Surface only the exceptions (like the drifted-keys convention).
            my %lbl = (
                mismatch => 'MISMATCH (corruption)',
                stale    => 'stale (unverified; newer than manifest)',
                missing  => 'missing (recorded but absent)',
                error    => 'ERROR (could not hash)',
            );
            for my $f (@$frags) {
                next unless ref $f eq 'HASH';
                my ($nm, $st) = ($f->{name}, $f->{state});
                next unless defined $nm && defined $st && !ref $nm && !ref $st;
                next if $st eq 'match';
                printf "    %-11s %s\n", $nm, ($lbl{$st} // $st);
            }
        }
    }

    unless ($skip_drift) {
        print "\nConfig schema:\n";
        if (!$authd_up) {
            print "  (authd unavailable - schema state not checked)\n";
        } elsif ($schema_state eq 'unavailable') {
            printf "  unavailable (%s)\n", ($schema_detail // 'no data');
        } else {
            my $sum = (ref $sch eq 'HASH' && ref $sch->{summary} eq 'HASH')
                ? $sch->{summary} : {};
            printf "  %-14s (%d fragments: %d converged, %d regressed, "
                 . "%d absent)\n",
                ($schema_state eq 'clean' ? 'clean' : uc($schema_state)),
                _int($sum->{total}), _int($sum->{converged}),
                _int($sum->{regressed}), _int($sum->{absent});
            # Surface only DEGRADING rows. Copying the integrity block's
            # print-every-non-match rule would add six permanent `absent` lines
            # to every healthy box (six fragments have no boot seeder) and a
            # `regressed` line to every P-lazy-upgraded one — training the
            # operator to ignore the section.
            my $frags = (ref $sch eq 'HASH' && ref $sch->{fragments} eq 'ARRAY')
                ? $sch->{fragments} : [];
            for my $f (@$frags) {
                next unless ref $f eq 'HASH';
                my ($nm, $st) = ($f->{file}, $f->{state});
                next unless defined $nm && defined $st && !ref $nm && !ref $st;
                my $torn = _is_true($f->{torn});
                next unless $torn || $st eq 'ahead' || $st eq 'malformed'
                    || $st eq 'unreadable';
                printf "    %-11s %s%s (on-disk %s, this build %s)\n",
                    $nm, uc($st), ($torn ? ' TORN' : ''),
                    ((defined $f->{disk_version} && !ref $f->{disk_version})
                        ? $f->{disk_version} : '-'),
                    ((defined $f->{ceiling} && !ref $f->{ceiling})
                        ? $f->{ceiling} : '?');
            }
            my $remedy = _schema_remedy($schema_state);
            print "  -> $remedy\n" if defined $remedy;
        }
    }

    unless ($skip_drift) {
        print "\nRestore:\n";
        if (!$authd_up) {
            print "  (authd unavailable - restore status not checked)\n";
        } elsif ($restore_state eq 'idle') {
            # 10.E4 S3 (§7 D5, VD-E4-3): `idle` carries a detail in exactly one
            # case -- an open confirm window. The box IS idle and healthy, so it
            # does not degrade; but a restore attempted now will be REFUSED by
            # the fragment daemon holding that window, and the console has no
            # verb to clear most of them. D5 accepted the wedge and explicitly
            # refused to accept the silence, so this branch must not swallow the
            # detail the verdict built. (It did until S3: `idle` printed a fixed
            # string and the Summary note below is gated on state ne 'idle'.)
            if (defined $restore_detail) {
                print "  $restore_detail\n";
            } else {
                print "  clean (no restore transaction pending)\n";
            }
        } elsif ($restore_state eq 'incomplete') {
            printf "  INCOMPLETE (%s)\n",
                ($restore_detail // 'recovery pending');
            printf "    txn %s - recovery retries automatically; see the\n"
                 . "    authd audit log for the CRITICAL restore_recover trail\n",
                (ref $restore eq 'HASH' ? ($restore->{txn_id} // '?') : '?');
        } elsif ($restore_state eq 'recovered') {
            printf "  recovered (%s)\n",
                ($restore_detail // 'recovery completed');
        } elsif ($restore_state eq 'active') {
            print "  restore in progress\n";
        } else {
            printf "  unavailable (%s)\n", ($restore_detail // 'no data');
        }
    }

    my @notes;
    my @down = map { $_->{name} } grep { !$_->{up} } @daemons;
    push @notes, sprintf("%d/%d daemons up%s", $up, $total,
        (@down ? " (" . join(', ', @down) . " down)" : ""));
    unless ($skip_drift) {
        my @dn = map { $_->{domain} } grep { $_->{state} eq 'drift' } @drift;
        push @notes, sprintf("drift in %d/%d domains%s",
            $domains_drifted, $domains_total,
            (@dn ? " (" . join(', ', @dn) . ")" : ""));
        # 10.C6 S6d (D13): surface domains whose live state could not be read
        # (fail-closed 'error') — they degrade health, so name them only when any.
        if ($domains_error > 0) {
            my @en = map { $_->{domain} } grep { $_->{state} eq 'error' } @drift;
            push @notes, sprintf("read error in %d/%d domains%s",
                $domains_error, $domains_total,
                (@en ? " (" . join(', ', @en) . ")" : ""));
        }
        # Only mention HA/VPN when the box actually has such interfaces, so a
        # plain box's summary line is unchanged.
        if ($iface_drift_total > 0) {
            my @idn = map { $_->{iface} }
                grep { $_->{state} eq 'drift' || $_->{state} eq 'error' } @iface_drift;
            push @notes, sprintf("HA/VPN drift in %d/%d interfaces%s",
                $iface_drift_count, $iface_drift_total,
                (@idn ? " (" . join(', ', @idn) . ")" : ""));
        }
    }
    unless ($skip_drift) {
        # Only speak up when integrity is not a plain "clean" (keep a healthy
        # box's summary line quiet), matching the HA/VPN "only if present" style.
        if ($authd_up && $integ_state ne 'clean') {
            push @notes, "config integrity: $integ_state"
                . (defined $integ_detail ? " ($integ_detail)" : "");
        }
        # Schema note speaks up only on a DEGRADING state, not on every
        # not-clean one: `regressed` is the ratified P-lazy steady state (D9),
        # so a `ne 'clean'` predicate would put a permanent note on the summary
        # line of every upgraded box — the exact noise this convention exists to
        # prevent, and a defeat of the D9 rationale itself.
        if ($authd_up && $schema_degraded) {
            push @notes, "config schema: $schema_state"
                . (defined $schema_detail ? " ($schema_detail)" : "");
        }
        # Restore note follows the same only-if-not-clean convention: a box
        # with no pending/just-recovered transaction stays quiet.
        # 10.E4 S3: ...with one exception, `idle` WITH a detail -- an open
        # confirm window (see the Restore block above). It is the one idle state
        # an operator must act on before a restore will be accepted, so it earns
        # a summary note; a genuinely clean box still stays quiet because its
        # detail is undef.
        if ($authd_up && ($restore_state ne 'idle' || defined $restore_detail)) {
            push @notes, "restore: $restore_state"
                . (defined $restore_detail ? " ($restore_detail)" : "");
        }
    }
    push @notes, ($version_consistent
        ? sprintf("versions consistent (%s)", ($common_version // '?'))
        : "VERSION MISMATCH (" . join(', ', sort keys %versions) . ")");
    printf "\nSummary: %s.\n", join('; ', @notes);

    return $healthy ? 0 : 1;
}

# ---- schema (10.E2 S2) ---------------------------------------------------
# I/O shell only: every decision it makes lives in the PURE _schema_verdict /
# _schema_exit above, which is what makes the exit contract unit-testable (the
# t/ successor evals only the helper block, never this sub).
sub do_schema {
    my $to = defined $timeout ? $timeout : 3;
    $to = 1  if $to < 1;
    $to = 60 if $to > 60;

    my ($resp, $err) = _probe($sock_path,
        { op => 'get_schema_state', actor => 'console' }, $to);

    my $rc = _schema_exit($resp);
    if ($rc == 2) {
        my $why = $err // (ref $resp eq 'HASH' && $resp->{error}
            ? 'authd refused the request' : 'no schema data');
        # Same cannot-run affordances as `status`: an actionable hint on the
        # human path, a well-formed envelope on the --json path.
        if ($json_out) {
            print JSON::PP->new->pretty->canonical->encode({
                ok    => JSON::PP::false,
                error => "cannot read schema state: $why",
            });
        } else {
            print STDERR "ogmaprotectctl schema: $why\n";
            print STDERR "ogmaprotectctl schema: run as root (the op is a "
                . "root-console read)\n" if $> != 0;
        }
        return 2;
    }

    my $ss = $resp->{schema_state};
    my ($state, undef, $detail) = _schema_verdict($resp);

    if ($json_out) {
        print JSON::PP->new->pretty->canonical->encode({
            ok           => JSON::PP::true,
            generated_at => _now_utc(),
            hostname     => _hostname(),
            schema_state => $ss,
            state        => $state,
            degraded     => ($rc ? JSON::PP::true : JSON::PP::false),
            ($detail ? (detail => $detail) : ()),
        });
        return $rc;
    }

    my $sum   = (ref $ss->{summary}   eq 'HASH')  ? $ss->{summary}   : {};
    my $frags = (ref $ss->{fragments} eq 'ARRAY') ? $ss->{fragments} : [];

    print "Config schema:\n";
    # ON-DISK is what the file literally carries ('-' when it has no `version:`
    # line at all, which is normal for the omit-at-default fragments); READS-AS
    # is what the owning daemon resolves to. Keeping both visible is the point:
    # at the first ceiling bump they diverge, and that divergence is the whole
    # question an operator is asking.
    printf "  %-11s %-11s %8s %9s %8s  %s\n",
        'FRAGMENT', 'FILE', 'ON-DISK', 'READS-AS', 'CEILING', 'STATE';
    for my $f (@$frags) {
        next unless ref $f eq 'HASH';
        my $num = sub {
            my $v = shift;
            return (defined $v && !ref $v) ? $v : '-';
        };
        printf "  %-11s %-11s %8s %9s %8s  %s%s\n",
            ($f->{fragment} // '?'),
            (($f->{file} // '?') =~ s/\.yaml\z//r),
            $num->($f->{disk_version}),
            $num->($f->{effective_version}),
            $num->($f->{ceiling}),
            ($f->{state} // '?'),
            (_is_true($f->{torn}) ? ' TORN' : '');
    }
    printf "\n  %d fragments: %d converged, %d regressed, %d ahead, "
         . "%d malformed, %d unreadable, %d absent\n",
        _int($sum->{total}), _int($sum->{converged}), _int($sum->{regressed}),
        _int($sum->{ahead}), _int($sum->{malformed}),
        _int($sum->{unreadable}), _int($sum->{absent});

    if ($state eq 'clean') {
        print "  OK - every fragment is at a version this build supports.\n";
    } else {
        printf "  %s%s\n", uc($state),
            (defined $detail ? " ($detail)" : '');
        my $remedy = _schema_remedy($state);
        print "  -> $remedy\n" if defined $remedy;
    }
    return $rc;
}

# 10.E3 S3 -- the console arm of the support bundle.
#
# EXIT CONTRACT (Gate-0 §6.1 requires it be pinned). Deliberately the SAME
# 0/1/2 vocabulary every other diagnostic verb here uses -- `status`, `schema`
# and `audit-verify` are all 0-healthy / 1-degraded / 2-cannot-run, and a fourth
# private dialect would make a cron consumer learn three codes for one new verb:
#
#   0  bundle produced; every source either collected or legitimately empty
#   1  bundle produced, but at least one source FAILED -- still useful, and the
#      MANIFEST says which. `denied` rows alone do NOT move the code off 0: on
#      this arm none can occur, and treating a permission boundary as a failure
#      would train operators to ignore exit 1.
#   2  no bundle was DELIVERED: submit refused, the job failed or was killed,
#      the disk pre-flight refused, authd is unreachable -- or the artifact was
#      produced but could not be written to --out. In that last class a staged
#      bundle DOES exist; every such leg names its path on stderr and prints it
#      on stdout, so "exit 2" never means "nothing was written to disk".
# 10.E4 S3 (§7 D4 / §3 M3) — `backup stage --file <path>`.
#
# WHY THIS IS CLIENT-SIDE AND NOT AN authd OP. D4's own rationale: this is "the
# only affordance here that is NOT a privilege change -- staging copies a file
# the operator already holds into a directory authd already unveils". ctl runs
# as root and can write there directly, so a daemon op would add a wire surface,
# a registry row and a path-authority decision for zero privilege gain. It would
# also be a second writer of the staging path shape, which is the drift §7 D14
# declined a second import verb to avoid. Concretely: t/op_contract.t requires
# every `op =>` literal here to name a registered row, so a `stage_backup` op
# would cascade into ops.c, test_ops.c's exact-size assert and six golden
# includes -- all to copy a file.
#
# WHAT THE DAEMON REQUIRES OF THE RESULT (backup.c ogma_backup_upload_path_ok):
# an absolute path, no "..", the exact prefix <staging>/uploads/upload-, at
# least 8 lowercase hex, then ".ogma". 16 hex from /dev/urandom clears it.
#
# THE WRITE SHAPE IS THE HARDENED ONE, not `open '>'`, because the destination
# directory is group-writable by _www: a same-directory temp opened
# O_CREAT|O_EXCL|O_NOFOLLOW, then rename(2) onto the final name, unlinking the
# temp on every failure leg. That is ogma_staging_copy_file's post-10.E3 shape
# and it closes the plant-a-symlink race. The residual -- a _www overwrite
# between stage and apply -- is VD-E4-19, and it is a DoS rather than a
# substitution because every fragment daemon re-pins the staged sha.
sub do_backup_stage {
    my $staging = '/var/db/ogmaprotect/staging';
    my $uploads = "$staging/uploads";

    die "backup stage requires --file\n" unless defined $file && $file ne '';
    die "backup stage: $file: not a regular file\n" unless -f $file;
    die "backup stage: $file: is a symlink\n" if -l $file;

    # Deliberately do NOT create the directory. It is created at INSTALL --
    # `${INSTALL} -d -o root -g www -m 770` in the Makefile, the PLIST and
    # ogmaprotect-setup -- never by authd at start, so a missing one means the
    # install is incomplete, and ctl minting it would set its owner/mode and
    # could silently break the web upload path. Name the real remedy: telling
    # the operator to restart authd (an earlier draft's message) would be a
    # wrong instruction at 3am, because nothing in authd's boot path creates it.
    unless (-d $uploads) {
        die "backup stage: $uploads does not exist -- this install is "
          . "incomplete.\n  Create it with the ownership the web tier needs:\n"
          . "    mkdir -p $uploads && chown root:www $uploads && chmod 770 $uploads\n";
    }

    open my $rnd, '<', '/dev/urandom' or die "open /dev/urandom: $!\n";
    binmode $rnd;
    read($rnd, my $seed, 8) == 8 or die "short read from /dev/urandom\n";
    close $rnd;
    my $hex = unpack 'H*', $seed;

    my $dest = "$uploads/upload-$hex.ogma";
    my $tmp  = "$dest.tmp";
    # O_NOFOLLOW on the SOURCE too, and fstat the handle rather than the path.
    # The -f/-l pair above is a usability check, not a security one: between it
    # and the open, anyone who controls the source directory could swap the file
    # for a symlink and have root copy the target into a www-group-readable
    # directory. Opening with O_NOFOLLOW and validating the descriptor closes
    # the window; the path is never trusted twice.
    sysopen(my $in, $file, O_RDONLY | O_NOFOLLOW) or die "read $file: $!\n";
    binmode $in;
    (-f $in) or do { close $in; die "backup stage: $file: not a regular file\n" };
    sysopen(my $ofh, $tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0640)
        or do { close $in; die "create $tmp: $!\n" };
    binmode $ofh;

    # 10.B5 envelope sniff + sha256, BOTH computed over the bytes as they stream
    # off the SOURCE -- never by re-reading the destination.
    #
    # This is the whole security argument for the verb. <staging>/uploads is
    # mode 0770 group www, and authd's own threat model names a compromised
    # slowcgi worker as uid www. If the sha were taken by re-opening $dest after
    # the rename, _www could replace the file in that window and get THEIR
    # bundle hashed -- and the value we print is exactly what the operator
    # passes to `backup apply --staged-sha256`, which every downstream re-pin
    # then compares against. Re-pinning only catches a swap AFTER the hash; a
    # swap before it is a clean substitution, and the console sentinel carries
    # backup:import-unsigned, so an unsigned attacker bundle would be accepted.
    # Hashing the source makes a _www overwrite what VD-E4-19 claims it is: a
    # denial of service, because the pin then simply fails to match.
    my $ctx = eval { require Digest::SHA; Digest::SHA->new(256) };
    my $bytes = 0;
    my $magic = '';
    my $ok = eval {
        while (1) {
            my $n = read($in, my $buf, 65536);
            # read() returns undef on error and 0 at EOF; a bare truthiness
            # test conflates them, so an EIO mid-copy would rename a TRUNCATED
            # bundle into place and report ok:true with a matching sha.
            die "read $file: $!\n" unless defined $n;
            last if $n == 0;
            $magic .= substr($buf, 0, 8 - length($magic))
                if length($magic) < 8;
            print $ofh $buf or die "write $tmp: $!\n";
            $ctx->add($buf) if $ctx;
            $bytes += length $buf;
        }
        1;
    };
    my $err = $@;
    close $in;
    close $ofh or do { $err ||= "close $tmp: $!\n"; $ok = 0 };
    unless ($ok) { unlink $tmp; die $err || "staging copy failed\n" }
    unless (rename $tmp, $dest) {
        my $e = $!;
        unlink $tmp;
        die "rename $tmp -> $dest: $e\n";
    }

    # The outer AEAD magic is the literal "OGMAENC1" at offset 0
    # (backup_crypto.c), taken from the first bytes read above.
    #
    # Why an encrypted bundle gets NO sha256 here: `backup apply
    # --staged-sha256` pins the SIGNATURE CLASSIFY, and authd decrypts an
    # envelope IN PLACE *before* that gate -- so the value apply needs is the
    # sha of the recovered PLAINTEXT, which only a prior validate/dry-run can
    # report. Printing the envelope's own sha as if it were apply-ready would
    # hand the operator a value that fails the pin with "bundle unreadable or
    # altered" -- a message that reads as tampering -- and by then the staged
    # bytes have already been overwritten with the plaintext, so retrying with
    # the same value can never succeed. Say so instead.
    my $encrypted = (substr($magic, 0, 8) eq 'OGMAENC1') ? 1 : 0;

    my %res = (
        ok          => JSON::PP::true,
        backup_path => $dest,
        bytes       => $bytes,
        encrypted   => $encrypted ? JSON::PP::true : JSON::PP::false,
    );
    if ($encrypted) {
        $res{next} = 'this bundle is encrypted: run `backup dry-run` with '
                   . 'OGMACTL_PASSPHRASE set and use the staged_sha256 it '
                   . 'returns for `backup apply --staged-sha256`';
    } else {
        # $ctx hashed the SOURCE stream; _sha256_file falls back to hashing the
        # source PATH, never $dest -- see the streaming comment above.
        my $sha = $ctx ? $ctx->hexdigest : _sha256_file($file);
        if (defined $sha && $sha =~ /\A[0-9a-f]{64}\z/) {
            $res{sha256} = $sha;
            $res{next} = 'pass this to `backup apply --staged-sha256`';
        } else {
            $res{next} = 'could not compute sha256 here: run `backup dry-run` '
                       . 'and use the staged_sha256 it returns';
        }
    }
    print JSON::PP->new->pretty->canonical->encode(\%res);
    return 0;
}

# sha256 of a file. Digest::SHA when the module is present (the guarded-require
# idiom _hostname uses), else OpenBSD base sha256(1). undef if neither works --
# the caller then points at dry-run rather than printing a wrong value.
sub _sha256_file {
    my ($p) = @_;
    my $d = eval {
        require Digest::SHA;
        Digest::SHA->new(256)->addfile($p)->hexdigest;
    };
    return $d if defined $d && $d =~ /\A[0-9a-f]{64}\z/;
    # LIST-form open: no shell, so the path is an argv element and can never be
    # word-split or interpreted. (`\Q` would have been the wrong tool here --
    # quotemeta escapes REGEX metacharacters, not shell ones; it happens to
    # neutralise a shell too, but relying on that coincidence in a root-run
    # script is how injection bugs get written.)
    open my $ph, '-|', 'sha256', '-q', $p or return undef;
    my $out = <$ph>;
    close $ph;
    return undef unless defined $out;
    chomp $out;
    return $out =~ /\A[0-9a-f]{64}\z/ ? $out : undef;
}

# 10.B5.1: read the deployment backup anchor's PUBLIC half and report its
# identity. Read-only, local, no daemon — a root file read like `backup stage`.
# Never touches the sec half, the helper socket, or any authd op.
sub do_backup_anchor {
    my $pub = '/var/db/ogmaprotect/capkeys/.backup-anchor.pub';
    unless (-f $pub) {
        die "backup anchor: $pub not found -- this box has no deployment "
          . "backup anchor yet (authd mints one at first start).\n";
    }
    open my $fh, '<', $pub or die "backup anchor: open $pub: $!\n";
    my @lines = <$fh>;
    close $fh;
    # signify pubkey file: line 1 "untrusted comment: ...", line 2 base64 body.
    my $b64 = defined $lines[1] ? $lines[1] : '';
    chomp $b64;
    my $keynum = 'unknown';
    my $raw = eval { require MIME::Base64; MIME::Base64::decode_base64($b64) };
    if (defined $raw && length($raw) >= 10) {
        # 2-byte pkalg id, then the 8-byte keynum embedded in every signature.
        $keynum = unpack 'H16', substr($raw, 2, 8);
    }
    my $sha = _sha256_file($pub);
    print "anchor keynum: $keynum\n";
    print "public key:    $b64\n";
    print "pub sha256:    " . (defined $sha ? $sha : 'unavailable') . "\n";
    print "path:          $pub\n";
    print "\nHand the public key above (or the .pub file) to a monitor or\n";
    print "fleet-mate to verify this deployment's signed backups; cross-check\n";
    print "the keynum. Backup-anchor custody is covered in docs/RECOVERY.md.\n";
    return 0;
}

sub do_support_bundle {
    my (@argv) = @_;
    # $out is the FILE-SCOPE lexical that GetOptions already populated (it runs
    # over @ARGV before @args is taken, so --out never reaches @argv here). A
    # local `my $out` plus a hand-rolled arg loop -- the first draft -- shadowed
    # it with undef, so --out silently printed the staged path and wrote no file
    # at all, while the tab told operators to run exactly that command.
    # $out is declared above the dispatch, so the early-exit initializer hazard
    # `status` and `schema` document does not apply.
    if (@argv) {
        print STDERR "support-bundle: unknown argument '$argv[0]'\n";
        return 2;
    }
    if (defined $out && !length $out) {
        print STDERR "support-bundle: --out needs a path\n";
        return 2;
    }

    # The handling statement, BEFORE the artifact exists. §7 D19 exempts this
    # arm from D14's acknowledgement, not from the truth: without this an
    # operator runs one command and a file carrying every username ever typed
    # into this box lands on their laptop with no notice at all.
    print STDERR <<'WARN';
ogmaprotectctl: this bundle is PRIVILEGED. It contains every username ever
  submitted to this box (INCLUDING failed sign-ins), the source IP of every
  management session, /var/log/authlog unredacted, and an inventory of which
  secrets exist for which peers. On an unadopted box it also carries the RAW
  unbound.conf / dhcpd.conf / bgpd.conf / ospfd.conf bodies, cut only by
  length -- a brownfield migration can leave TSIG keys in the unbound/dhcpd
  bodies. The bgpd/ospfd bodies have their key-bearing directives redacted
  (10.E3.3), but that is a triage courtesy, not a security control: a key in
  an included file is not covered.
  Alert channel webhook URLs are REMOVED from every archive (10.E8.4); the
  archive records nothing about whether a channel had one. It contains none of
  OgmaProtect's OWN private keys, no users.db, and no core-dump content.
  Encrypt it in transit and delete it when the incident closes.
  See SUMMARY.txt inside the archive for what it cannot tell you.
WARN

    my $r = eval {
        daemon_call($sock_path, { op => 'support_bundle', actor => 'console' },
            OGMA_CTL_CALL_TIMEOUT);
    };
    if ($@ || !defined $r) {
        my $e = $@ || 'no reply';
        chomp $e;
        print STDERR "support-bundle: cannot reach authd: $e\n";
        return 2;
    }
    unless ($r->{ok}) {
        print STDERR 'support-bundle: refused: '
            . ($r->{error} // 'unknown') . "\n";
        return 2;
    }
    my $job = $r->{job_id} // $r->{txn_id};
    unless (defined $job && length $job) {
        print STDERR "support-bundle: authd returned no job handle\n";
        return 2;
    }
    print STDERR "support-bundle: collecting (job $job)...\n";

    my $body = eval {
        poll_job_terminal($sock_path, undef, $job, OGMA_CTL_CALL_TIMEOUT);
    };
    if ($@) {
        my $e = $@;
        chomp $e;
        print STDERR "support-bundle: $e\n";
        return 2;
    }
    unless (ref $body eq 'HASH' && $body->{ok}) {
        print STDERR 'support-bundle: failed: '
            . ((ref $body eq 'HASH' && $body->{error}) || 'unknown') . "\n";
        return 2;
    }
    my $path = $body->{backup_path} // '';
    unless ($path =~ m{\A/[\w./-]+\.tar\z}) {
        print STDERR "support-bundle: authd returned no usable path\n";
        return 2;
    }

    if (defined $out) {
        # 0600 from birth, never a chmod after the bytes are on disk: the
        # window between create and chmod is exactly when this file is most
        # worth stealing.
        # EVERY failure leg below leaves the STAGED artifact on disk. Exit 2
        # means "no bundle was delivered to --out", NOT "no bundle exists" --
        # so each leg names the staged path on stderr and prints it on stdout,
        # or the most sensitive file this product writes sits in the staging
        # tree with the operator told there is nothing to collect. (--out
        # colliding with an existing file is the realistic trigger: O_EXCL.)
        my $ok = open(my $in, '<', $path);
        unless ($ok) {
            print STDERR "support-bundle: cannot read $path: $!\n";
            print STDERR "support-bundle: the staged bundle remains at $path\n";
            print "$path\n";
            return 2;
        }
        binmode $in;
        # Declared OUTSIDE the condition: `my` inside an unless() test scopes
        # to the test, not the block, so every later use would be a stray
        # global under `use strict`.
        my $ofh;
        unless (sysopen($ofh, $out, O_WRONLY | O_CREAT | O_EXCL, 0600)) {
            print STDERR "support-bundle: cannot create $out: $!\n";
            print STDERR "support-bundle: the staged bundle remains at $path\n";
            close $in;
            print "$path\n";
            return 2;
        }
        binmode $ofh;
        my $buf;
        while (my $n = read($in, $buf, 65536)) {
            unless (print {$ofh} $buf) {
                print STDERR "support-bundle: write to $out failed: $!\n";
                print STDERR
                    "support-bundle: the staged bundle remains at $path\n";
                close $in; close $ofh; unlink $out;
                print "$path\n";
                return 2;
            }
        }
        close $in;
        unless (close $ofh) {
            print STDERR "support-bundle: write to $out failed: $!\n";
            print STDERR "support-bundle: the staged bundle remains at $path\n";
            unlink $out;
            print "$path\n";
            return 2;
        }
        # Remove the staged original, the `backup export` precedent. Without
        # this the most sensitive artifact the product writes sits in the
        # staging tree for the full reap grace as an unannounced duplicate of
        # the file the operator believes they just moved.
        unlink $path;
        print "$out\n";
    } else {
        print "$path\n";
    }

    # Exit 1 iff a source FAILED. Derived from the manifest the daemon already
    # reported, not re-derived here: the daemon sets `failed_sources` on the
    # terminal body so this verdict cannot drift from the artifact.
    my $failed = $body->{failed_sources};
    if (defined $failed && $failed > 0) {
        print STDERR "support-bundle: $failed source(s) failed; see MANIFEST\n";
        return 1;
    }
    return 0;
}

sub usage {
    # 10.E8 S3: an EXPLICIT --help/-h/help goes to STDOUT, so piping it into a
    # pager is not an empty screen; the zero-args ERROR path keeps STDERR.
    my ($fh) = @_;
    $fh ||= \*STDERR;
    print {$fh} "Usage: ogmaprotectctl user add <name>\n";
    print {$fh} "       ogmaprotectctl user set-password <name>\n";
    print {$fh} "         (password on stdin, or OGMACTL_PASSWORD=... -- never argv,\n";
    print {$fh} "          which is world-visible in ps)\n";
    print {$fh} "       ogmaprotectctl user status <name>\n";
    print {$fh} "       ogmaprotectctl user unlock <name>\n";
    print {$fh} "         (auth lockout: repeated wrong passwords refuse the RIGHT one\n";
    print {$fh} "          for lockout_window_min minutes, on the web AND here, with no\n";
    print {$fh} "          message saying so. status reports locked/failures/window_end;\n";
    print {$fh} "          unlock clears that account's failure rows. Root console, no\n";
    print {$fh} "          session needed. Under lockout_scope `ip` the counters are\n";
    print {$fh} "          keyed by source address, so a per-user unlock may not lift it)\n";
    print {$fh} "       ogmaprotectctl role grant <user> <role>\n";
    print {$fh} "       ogmaprotectctl role revoke <user> <role>\n";
    print {$fh} "       ogmaprotectctl cert status\n";
    print {$fh} "       ogmaprotectctl cert self-signed --cn NAME [--san a,b] [--days N]\n";
    print {$fh} "       ogmaprotectctl cert import <cert-path> <key-path>\n";
    print {$fh} "       ogmaprotectctl cert confirm [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl cert cancel [<txn_id>]\n";
    print {$fh} "         (root console, no session needed; --confirm-timeout N sets the\n";
    print {$fh} "          60..900s auto-revert window. Omit <txn_id> to resolve the open\n";
    print {$fh} "          window. --cn/--san are DNS names only, never an IP)\n";
    print {$fh} "       ogmaprotectctl help recover\n";
    print {$fh} "       ogmaprotectctl --help | -h | help\n";
    print {$fh} "       ogmaprotectctl login <user> <pass>  (test)\n";
    print {$fh} "       ogmaprotectctl backup stage --file path\n";
    print {$fh} "       ogmaprotectctl backup anchor    # local: this box's backup-anchor identity\n";
    print {$fh} "       ogmaprotectctl backup export --session SID [--out path]\n";
    print {$fh} "       ogmaprotectctl backup validate [--session SID] --path path\n";
    print {$fh} "       ogmaprotectctl backup dry-run [--session SID] --path path\n";
    print {$fh} "       OGMACTL_CONFIRM=1 ogmaprotectctl backup apply [--session SID] --path path --staged-sha256 HEX\n";
    print {$fh} "         (stage copies a bundle into the staging dir the daemon reads\n";
    print {$fh} "          from and prints the --path and --staged-sha256 to use;\n";
    print {$fh} "          validate/dry-run/apply run WITHOUT --session from the root\n";
    print {$fh} "          console -- the restore path for a box with no accounts.\n";
    print {$fh} "          export still requires --session.\n";
    print {$fh} "          OGMACTL_PASSPHRASE=... on export encrypts the bundle; on\n";
    print {$fh} "          validate/dry-run/apply decrypts an encrypted bundle.\n";
    print {$fh} "          --accept-foreign on validate/dry-run/apply proceeds with a\n";
    print {$fh} "          bundle this box cannot attest -- for a rebuilt box whose\n";
    print {$fh} "          deployment root key is gone. Root console only, per command,\n";
    print {$fh} "          audited. Restoring .cap-master from custody is safer and is\n";
    print {$fh} "          the first option in README.restore section 8. It does NOT\n";
    print {$fh} "          waive decryption: no passphrase is still no import)\n";
    print {$fh} "       ogmaprotectctl job <job_id> [--session SID]\n";
    print {$fh} "         (poll a background job to its result; a sessionless\n";
    print {$fh} "          `backup apply` prints the id to re-attach with)\n";
    print {$fh} "       ogmaprotectctl pf confirm [<txn_id>] [--session SID]\n";
    print {$fh} "       ogmaprotectctl pf cancel [<txn_id>] [--session SID]\n";
    print {$fh} "         (without --session: console recovery via the root pfd socket.
";
    print {$fh} "          Omit <txn_id> to resolve the open window -- it is read from
";
    print {$fh} "          get_pf, the same fragment `ogmaprotectctl windows` prints)\n";
    print {$fh} "       ogmaprotectctl address confirm|cancel [<interface>] [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl carp confirm|cancel [<interface>] [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl v6 confirm|cancel [<interface>] [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl tunnel confirm|cancel [<interface>] [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl wg confirm|cancel [<interface>] [<txn_id>]\n";
    print {$fh} "         (root console, no session needed: resolve an open commit-confirm\n";
    print {$fh} "          window when the change destroyed your session -- cancel reverts\n";
    print {$fh} "          the pending change, confirm keeps it. Omit the arguments to\n";
    print {$fh} "          resolve the open window; the interface and txn_id are recovered\n";
    print {$fh} "          from netd's window fragments over the root socket. On netd one\n";
    print {$fh} "          armed window blocks EVERY netd apply until resolved)\n";
    print {$fh} "       ogmaprotectctl routes confirm|cancel [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl gateways confirm|cancel [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl dns confirm|cancel [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl ospf confirm|cancel [<txn_id>]\n";
    print {$fh} "       ogmaprotectctl bgp confirm|cancel [<txn_id>]\n";
    print {$fh} "         (root console, no session needed: the same break-glass pair for\n";
    print {$fh} "          the rtd, dnsd and routed windows. NO interface -- these key on\n";
    print {$fh} "          the txn alone, recovered from the owning daemon's own window\n";
    print {$fh} "          report when omitted. Dynamic routing is TWO pairs, never one:\n";
    print {$fh} "          at most one routing window can be open, and `ospf cancel`\n";
    print {$fh} "          refuses a bgp window naming the verb to use instead)\n";
    print {$fh} "       ogmaprotectctl ipsec confirm|cancel [<txn_id>]
";
    print {$fh} "       ogmaprotectctl update stage\n";
    print {$fh} "       OGMACTL_CONFIRM=1 ogmaprotectctl update apply\n";
    print {$fh} "       ogmaprotectctl update discard\n";
    print {$fh} "         (root console, no session needed: the two-step update. `stage`\n";
    print {$fh} "          downloads and verifies the release the last update check named\n";
    print {$fh} "          (run the check first; watch `ogmaprotectctl status` -> Staged:);\n";
    print {$fh} "          `apply` re-verifies the staged package, takes the pre-upgrade\n";
    print {$fh} "          snapshot, installs it with pkg_add and REBOOTS -- pick the outage;\n";
    print {$fh} "          `discard` throws the staged bytes away. Lite (package) edition only\n";
    print {$fh} "          in this release)\n";
    print {$fh} "         (root console, no session needed: the last domain. NO interface.
";
    print {$fh} "          CAVEAT: an ipsecd RESTART resolves an open window before its
";
    print {$fh} "          socket opens -- afterwards this verb honestly reports no window,
";
    print {$fh} "          the outcome is in the ipsecd audit trail, and the plane comes
";
    print {$fh} "          back DOWN because the keyed prior config is never on disk)
";
    print {$fh} "       ogmaprotectctl windows [--json] [--timeout SECS]\n";
    print {$fh} "         (root console, no session needed: EVERY open confirm window on\n";
    print {$fh} "          this box, with its txn_id, interface, actor, countdown and\n";
    print {$fh} "          revert state -- the one surface that names them all. Read it\n";
    print {$fh} "          BETWEEN `status` telling you a window is open and typing one of\n";
    print {$fh} "          the confirm|cancel verbs above. Two independent sources: authd's\n";
    print {$fh} "          marker census and the owning daemons; where they disagree the\n";
    print {$fh} "          row says so and is never reported clean. Exit 0 whether or not a\n";
    print {$fh} "          window is open; 2 only when NO source could be read)\n";
    print {$fh} "       ogmaprotectctl fsck-autorepair [status] [--json] [--timeout SECS]\n";
    print {$fh} "       ogmaprotectctl fsck-autorepair enable --session SID\n";
    print {$fh} "       OGMACTL_CONFIRM=1 ogmaprotectctl fsck-autorepair disable --session SID\n";
    print {$fh} "         (unattended fsck -y after an unclean shutdown, default ON. status is\n";
    print {$fh} "          root-console and sessionless; enable/disable need a session with\n";
    print {$fh} "          system:power:write. disable re-arms the console halt on the next\n";
    print {$fh} "          unclean boot, hence the interlock. The setting is INERT until the\n";
    print {$fh} "          /etc/rc patch is applied: doas ogma-fsck-rc-apply enable)\n";
    print {$fh} "       ogmaprotectctl status [--json] [--no-drift|--sockets-only] [--timeout SECS]\n";
    print {$fh} "         (root console: daemon socket liveness + config-drift summary +\n";
    print {$fh} "          per-interface HA/VPN drift + config-dir manifest integrity +\n";
    print {$fh} "          config-fragment schema state + crash-safe-restore transaction status;\n";
    print {$fh} "          --sockets-only for a fast liveness-only poll; --no-drift skips the\n";
    print {$fh} "          drift + integrity + schema + restore analysis; exit 0 healthy, 1 degraded,\n";
    print {$fh} "          2 cannot-run)\n";
    print {$fh} "       ogmaprotectctl schema [--json] [--timeout SECS]\n";
    print {$fh} "         (root console: each canonical config fragment's on-disk schema\n";
    print {$fh} "          version against this build's ceiling - converged / regressed /\n";
    print {$fh} "          ahead / torn / malformed / unreadable / absent. Exit 0 every fragment readable\n";
    print {$fh} "          by this build, 1 at least one ahead/torn/malformed/unreadable,\n";
    print {$fh} "          2 cannot-run. `regressed` (below the ceiling) is the expected\n";
    print {$fh} "          post-upgrade state and does NOT fail)\n";
    print {$fh} "       ogmaprotectctl audit-verify [--session SID]\n";
    print {$fh} "         (at-rest integrity of every daemon's live audit log: per-daemon\n";
    print {$fh} "          counters + first bad seq, plus the generation-ledger verdict that\n";
    print {$fh} "          catches truncation and cross-generation rollback. Counters only,\n";
    print {$fh} "          never log content. Run it REGULARLY: the ledger only remembers\n";
    print {$fh} "          generations a run actually observed. Exit 0 clean, 1 non-clean or\n";
    print {$fh} "          the ledger could not be written, 2 cannot-run.\n";
    print {$fh} "          Without --session: root console arm, so it still works when the\n";
    print {$fh} "          auth database itself is suspect)\n";
    print {$fh} "       ogmaprotectctl support-bundle [--out path]\n";
    print {$fh} "         (root console: collect a support bundle -- logs, config, drift and\n";
    print {$fh} "          control-plane state -- into one tar. Sessionless, so it works with\n";
    print {$fh} "          the web tier down or users.db corrupt. THE ARCHIVE IS PRIVILEGED:\n";
    print {$fh} "          it carries every username ever submitted incl. failed sign-ins, the\n";
    print {$fh} "          source IP of every management session, and authlog unredacted. Alert\n";
    print {$fh} "          webhook URLs are removed from every archive. It carries NO private\n";
    print {$fh} "          keys and no core content. --out writes it 0600; without --out the\n";
    print {$fh} "          staged path is printed. THREE artifact sizes: this console arm\n";
    print {$fh} "          produces the COMPLETE 41 sections; the box owner's web collection\n";
    print {$fh} "          cannot carry schema-state or config-integrity (39); an ogma-support\n";
    print {$fh} "          identity gets a narrowed 37 -- no authlog, no restore-status, and the\n";
    print {$fh} "          raw daemon-config previews stripped. Exit 0 all sources collected, 1\n";
    print {$fh} "          produced with at least one failed source, 2 cannot-run)\n";
}
