Commit Graph

787 Commits

Author SHA1 Message Date
vh d419b11d43 docs(ipv6): close the NH3 multi-prefix question by operator ruling
The gateway has no IP-passthrough mode, confirmed by the operator with
admin access to it, so the inexpensive path of letting the UDM take the
delegation whole and carve it natively is unavailable at this site.

What remains is a separate DHCPv6 client presenting several identities,
which needs recabling to reach the gateway, splits routing so that one
device handles v4 and another v6, and above all moves IPv6 off the
UDM's zone firewall, leaving the entire policy to be rebuilt elsewhere
before any host could safely hold a globally routable address. The
operator has declined it and the LANs stay without v6.

The index entry also still asserted the superseded single-delegation
conclusion as fact; it now carries the corrected reading alongside the
ruling, so the finding and the decision not to act on it arrive
together rather than the first inviting a retry of the second.
2026-08-24 07:05:49 -07:00
vh bd209951ac docs(ipv6): correct the NH3 delegation finding — eight /64s, not one
The note concluded AT&T delegates a single /64 and that reading the
address pattern as a /60 was a mistake. A later finding in the same
session overturned that by reading the BGW's own statistics page, and
that correction was never written down; it survived only in the
transcript and surfaced again while assessing whether more prefixes
could be claimed.

The gateway holds the /60 and rations it, keeping half for itself and
delegating the top eight /64s one at a time. Both observations agree:
a prefix-id only carves within a delegation already held, so the
earlier test could not have moved a lone /64 regardless. The limit is
that UniFi solicits once, which makes the requester the ceiling rather
than the carrier.
2026-08-24 07:02:53 -07:00
vh d127e29fac docs(ipv6): retire the next-candidates line now that all three are done
Replaces it with why the remaining segments have no eligible hosts:
two are appliance-only and three have no IPv6 enabled yet, pending the
firewall-policy pass that SLAAC on a client segment would require.
2026-08-23 22:39:51 -07:00
vh 4e83395ddf docs(ipv6): all three esh-server hosts now carry the segment name
esh-pve-nas and esh-vm-db join esh-docker-vm on 4411:b105, at
:50:55 and :50:60 respectively, each applied by the same prefix-deriving
if-up.d hook so the last two groups read straight off the IPv4 address.

Two obstacles are recorded because both will recur. The Proxmox node had
link-local only despite every relevant sysctl appearing correct, because
its bridge carries per-interface forwarding and the kernel ignores router
advertisements on a forwarding interface unless accept_ra is explicitly
two rather than one. The fix takes the advertised prefix while declining
the default route, so the hypervisor gains an address without any change
to how it routes; this was verified after applying, with the v6 default
route count still at zero.

The database VM refuses key authentication for the privileged accounts
and its unprivileged login cannot escalate without a password, so the
hook went in through the QEMU guest agent from the hypervisor, which
executes as root inside the guest. The document notes the base64
indirection needed to get a multi-line script through intact.
2026-08-23 22:39:39 -07:00
vh ffb7fba346 docs: give the ESH IPv6 naming scheme a home, and make it real on one host
The scheme has existed since August as a single line of persistent
memory, which a snapshot then deleted. It is a naming convention
rather than temporal state, so it now lives in docs/pfi as a proper
document, and the memory entry is reduced to a pointer at it. The
document carries the full table, the address structure, the reasoning
about which slots can and cannot hold a name, and the recipe for
applying one to a host.

It also corrects the conclusion the original note ended on. That note
held that these names could never appear on the wire, which is true
of everything UniFi is able to assign but not of what a host can
assign to itself, and the distinction is the whole difference between
a joke and an address.

AdGuard on esh-docker-vm now holds the esh-server name, at
2607:73c0:402:1d02:4411:b105:50:45, where the segment identity and the
IPv4 address are both legible. It is applied by an if-up.d hook that
derives the prefix at runtime rather than hardcoding it, backgrounds
itself with a retry so it cannot stall interface bring-up, and adds
nothing to the existing interface configuration.

This is load-bearing rather than decorative. The gateway advertises an
IPv6 resolver to clients, macOS prefers it over the IPv4 one, and it
previously pointed at an address derived from that host's MAC.
2026-08-23 22:31:13 -07:00
vh 41091eef8f docs: restore the ESH IPv6 naming scheme, dropped by a snapshot
The six ESH LAN hexspeak names were recorded in fe3d765, refined in
959bb6e and 8be8a51, and then removed without comment by the memory
snapshot in 837fa36. Nothing referenced them afterwards, so the loss
went unnoticed until the operator asked for them tonight and no file
on the machine contained the string.

Recovering it was harder than it should have been, for a reason worth
recording: the table was written in uppercase and git log -S is
case-sensitive, so a history search that would otherwise have found it
immediately came back empty. The entry was eventually located in a
session transcript, which named the commit.

The restored text carries the original verbatim plus two additions. It
is marked as a naming convention rather than temporal state, since
that is what made it eligible for pruning in the first place. And its
conclusion that the names can never appear on the wire is corrected:
the reasoning holds for what UniFi can assign, but a Linux host can
take such an address itself, which was verified on esh-docker-vm along
with a persistence path that leaves the existing interface config and
SLAAC untouched.

The scheme's first practical use is noted against the resolver address
the ESH gateway now advertises, which currently depends on a MAC.
2026-08-23 22:26:39 -07:00
vh 6217d3993e fix(scriberr): force uv to copy rather than reflink when building envs
Scriberr builds each model backend's Python environment with uv at
container start. uv's default link mode reflinks or hardlinks out of
its cache, which fails on this overlayfs over ZFS combination and
reports it as a failure to clone a metadata file with errno 11,
resource temporarily unavailable. The wording points nowhere near the
cause.

The damage was partial and therefore easy to miss: WhisperX and
PyAnnote came up fine and the application served normally, while the
Parakeet and Sortformer backends were quietly absent. Setting
UV_LINK_MODE to copy trades a little disk and build time for
environments that actually materialise, and the occurrence count for
that error is zero on the following start.

The knob is exposed through the environment so it can be moved back to
the default if a future host does support reflinks.
2026-08-23 19:31:54 -07:00
vh efddb4e511 feat(scriberr): stand up transcription on ana-ml2, pinned to GPU1
Scriberr transcribes audio and video locally with WhisperX and
speaker diarization, and it lands on ana-ml2 rather than ana-docker
because the work is GPU-shaped: ana-docker offers eight cores already
shared with fifty containers and thirty-seven gigabytes of disk,
against ninety-six cores, terabytes on /tank and idle capacity on
GPU1. The reservation names device 1 explicitly, since GPU0 is fully
committed to the gen seat, and the container is confirmed to see that
card alone.

The image is built from source, which is not a preference. These are
Blackwell cards at sm_120; the published CUDA image covers Pascal
through Ada only, and the blackwell image the upstream README
documents has never been published at all. The path upstream actually
ships for sm_120 is Dockerfile.cuda.12.9, carrying CUDA 12.9 and cu128
torch, so that is what gets built. The compose header says so, because
the obvious cleanup is to swap in the published image and that would
silently drop the deployment to CPU.

Two configuration details are load-bearing and documented where
someone would go to change them. The application runs as uid 10001
rather than the usual 1000: that Dockerfile moves its user aside for
Ubuntu 24.04's own uid-1000 account and chowns /app accordingly, while
the entrypoint's remapping covers only the data directories, so at
1000 the process cannot open its database and restarts forever behind
a SQLite error that reads as though the machine were out of memory.
Secure cookies stay off while the service is reached over plain HTTP,
or sessions are dropped by the browser and login appears to loop for
no visible reason.

Storage is bind-mounted onto /tank because model weights run to
several gigabytes and the root pool on that host is nearly full.

Also adds the scriberr service alias to internal DNS, following the
existing alias convention so consumers name the service rather than
the box.
2026-08-23 19:28:33 -07:00
vh 22ae9cd480 ops(ana-gw): disable ACME and retract the all-port VIP claim
The ACME client is unbound from wan1, which removes the HTTP-01
challenge listener that had been holding port 80 open irrespective of
allowaccess. An external sweep of fifty-five ports against the WAN
address now finds nothing open at all, while the internal interface
still serves the GUI and SSH and retains its certificate, which
remains valid until late October and simply stops renewing.

The previous note's claim that four virtual IPs were unrestricted
all-port static NATs is withdrawn. A FortiOS virtual IP can be scoped
either by an explicit forwarded port or by a service binding on the
object, and the earlier parse inspected only the former. All four use
the latter, and the custom services behind them are narrow. None of
the fourteen is unrestricted.

Ground truth from outside is recorded in place of further config
reading, listing what each public address actually exposes. Three
configured mappings answer nothing at all and are noted as tidy-up
candidates for the migration rather than as exposure.
2026-08-23 16:02:20 -07:00
vh f39b66d2e1 ops(ana-gw): port 80 on the WAN address is the FortiOS ACME listener
The previous note attributed the open port to an ISP transparent
proxy. That was wrong. It terminates on the FortiGate: system acme is
bound to wan1, and FortiOS opens port 80 there to answer HTTP-01
challenges regardless of what allowaccess permits, which is why the
port stays open with the interface set to ping only. Every
non-challenge request returns a fixed 403 whose body reads ACME Access
Only. No DNAT is involved; of the fourteen virtual IPs only two land
on that address, neither on port 80.

The wrong conclusion came from a sniffer filtered on dst host, which
matches inbound packets alone and so excluded the replies being looked
for. Filtered bidirectionally the box is plainly seen emitting SYN-ACK
on port 80. The note records the rule.

Two consequences follow. The earlier warning that certificate renewal
would fail without http in allowaccess is retracted, since FortiOS
opens the challenge port itself. And the listener is not an
administrative surface, though its value is now marginal with WAN
administration closed, so the note records how to remove it and leaves
that decision open.

Also captured: four virtual IPs are all-port static NAT and map every
port of their external address.
2026-08-23 15:53:55 -07:00
vh 7bc9754e40 ops: adopt AES-128 on both Anaheim tunnels and close public admin
Both tunnels now negotiate AES-128 for ESP, applied make-before-break
so neither dropped waiting on its far end: the FortiGate was widened
to accept the new cipher alongside the old one first, then each UniFi
gateway was flipped. Single-stream throughput moves from 245 to 270
on the NH3 tunnel and from 268 to 304 on the ESH tunnel. Both network
objects were diffed against pre-change snapshots and the only field
that moved on either is the ESP cipher.

The proposal lists are left accepting AES-256 as well. The peers offer
only AES-128 so the extra entries are inert, and retaining them means
a gateway reverting cannot strand a tunnel.

With that up, the WAN administrative surfaces are closed. The
interface is back to permitting only ping, and the infra-ops account
is again restricted to RFC1918 space. Ports 443 and 22 were confirmed
closed from two separate sites and management over the tunnel still
works. The close was issued over the tunnel rather than over the WAN,
since withdrawing SSH from the interface while connected through it
would sever the session mid-command. The box now has no out-of-band
path, which the memory records explicitly.

Also captured: the two UniFi vault items have different shapes, one a
bare key and one a documentation note requiring extraction, which
produces an opaque nginx rejection if missed, and the ESH key's first
confirmed write.
2026-08-23 15:44:32 -07:00
vh 6edebe4864 ops: forwarding through a downstream WireGuard terminator is free
FortiOS has no WireGuard, so any WireGuard site-to-site has to
terminate behind the edge, and ana-wg already fills that role. The
earlier 767 Mbit/s figure was taken with traffic terminating on that
box, which left the forwarding case unmeasured and overstated what a
real deployment would see.

Measured properly, transit from NH3 through the tunnel and onward to
ana-docker returns 763.8 Mbit/s on one stream and 790.4 across eight,
so the forwarding hop costs nothing and the full threefold gain over
IPsec survives. The container uses around a fifth of four cores at
that rate, against the UniFi gateway spending a third of its own four
to move a third as much.

The note records what still needs deciding rather than presenting this
as ready to build: Anaheim hosts have to route to the terminator
directly or pay a hairpin through the edge, which was not measured;
the terminator introduces a failure mode the edge did not have, with a
fallback route as the mitigation; and the NH3 end needs its own
terminator, where a Linux host matches the measured figure while the
existing UniFi WireGuard server would likely land lower.

The test tunnel, its peer and the temporary route were all removed.
2026-08-23 15:25:05 -07:00
vh 062215e81a ops: the tunnel's per-flow limit is the UDM's software AES-CBC
Varying only the cipher settles what inference could not. AES-128 has
ten rounds against AES-256's fourteen, so a software-bound path must
speed up when the cipher gets cheaper while a hardware-offloaded one
will not move. Run as A/B/A on a single stream, AES-256 returns 232,
245 and 243 Mbit/s and AES-128 returns 282 and 275, a gain of about a
fifth at identical CPU. The FortiGate's offload engine is therefore
not the constraint, and the operator's reading of the UniFi side as
software crypto without acceleration is correct.

Two earlier claims of mine were wrong and are corrected in place. The
observation that the UDM sat at seven percent CPU was a sampling
artifact, since UniFi refreshes that statistic on the device report
interval and the windows used were four seconds; a sustained run reads
thirty-five percent, around one and a half of four cores. The
per-session offload hypothesis is refuted rather than merely unproven,
because it predicts no response to a cipher change.

The remaining figures follow from this. One stream is one core, eight
streams engage about three, and aggregate stops responding to cipher
choice once several cores are working because the path bounds it. CBC
chaining is what makes this expensive, as it serialises blocks and
prevents the AES instructions from pipelining, which is also why the
same gateway manages far more over WireGuard.

The cipher was restored and the network object verified unchanged
against its pre-test snapshot.
2026-08-23 15:11:51 -07:00
vh 8ecffa1fab ops: WireGuard over the same path carries one stream at 767 Mbit/s
The operator asked for a WireGuard test between ana-wg and NH3 across
the public internet, which is the arrangement that separates the path
from the crypto since neither gateway performs encryption in it. A
single stream returns 767 Mbit/s against 245 for IPsec over the same
wire, and eight streams return 763, so one flow already saturates the
path and there is no per-flow penalty whatsoever.

That settles several things at once. The limit is not the circuit,
the NH3 uplink, the ISP or the physical path, all of which carry 767
on one flow. Ranking the implementations, Linux WireGuard shows no
per-flow penalty, UniFi's WireGuard shows roughly 1.4x, and IPsec on
this pair shows 2.8x. Latency under a single bulk stream tells the
same story, rising to 12.7 ms on WireGuard against 102 ms on IPsec.

This overturns the earlier conclusion that changing transport was not
worth pursuing, which compared eight-stream figures and so understated
the gap for single-stream work by a factor of three. A WireGuard
site-to-site terminated on ana-wg now looks worth considering, and the
note records the open questions around failover and policy.

Attribution between the FortiGate and the UniFi gateway remains
unresolved, since both perform IPsec in the slow measurements, but the
remedy does not depend on which one is responsible.

The test tunnel was removed, ana-wg is back to its original three
peers and the generated keys were shredded.
2026-08-23 14:57:29 -07:00
vh d42e9d8712 ops(ana-gw): establish the per-flow cap is IPsec-specific, not capacity
The operator asked whether the 80F is simply out of capacity. It is
not. Routing a single flow between two Anaheim VLANs through the same
box, with no tunnel involved, sustains 940 Mbit/s, which saturates the
link, and eight flows over that path return the same figure. There is
therefore no per-session ceiling in the plain forwarding path and the
roughly 250 Mbit/s per-flow limit belongs to the IPsec datapath alone.

Measuring the second tunnel with the same probe supports this. The ESH
side, terminating on different gateway hardware at nearly half the
round-trip time, returns 268 Mbit/s on one stream against NH3's 245,
and 715 against 692 across eight. A window-limited path would have
been substantially faster at the shorter round-trip, so the agreement
between two dissimilar far ends is further evidence of a rate cap.

This also corrects the previous note, which named the FortiGate on
reasoning that could not separate the box from the protocol, since
every slow path was both. That separation is still not established:
both tunnels terminate on UniFi gateways running common firmware, so
the cap could belong to either side. The note records the tunnel
topology that would decide it and flags the bearing on the pending
replacement.
2026-08-23 14:48:34 -07:00
vh cf0cb2cbb3 ops(ana-gw): pin down what limits a single stream across the tunnel
The earlier note described the constraint as per-flow serialisation
without evidence. Pinning SO_SNDBUF and sweeping it shows what it
actually is: throughput holds flat between 224 and 247 Mbit/s across
a thirteenfold range of in-flight data while round-trip time scales
with the buffer instead, from 7.8 ms up to 107 ms. Retransmissions
are absent throughout. That is a fixed service rate with a standing
queue ahead of it, so socket buffer, window scaling and congestion
control are not the lever and should not be pursued.

The same measurements surface something with wider consequences than
throughput: a single bulk stream lifts tunnel latency from 6.9 ms to
102 ms average, 136 ms peak, with no loss. Interactive traffic
sharing the Anaheim link degrades sharply whenever anything moves
bulk data, and parallelising transfers makes that worse rather than
better.

Localisation rests on the FortiGate appearing in both slow paths and
in neither fast one, with aggregate throughput over the same security
association reaching 692 Mbit/s. Per-session NPU offload fits the
shape but was not confirmed on the device; the note records the test
that would settle it and flags the bearing this has on the pending
FortiGate replacement.
2026-08-23 14:42:17 -07:00
vh e41d19f1cb ops(ana-gw): close out the Anaheim tunnel cipher question
The AES-GCM cutover was authorised and attempted, NH3 side first. It
cannot be completed: UniFi's manual site-to-site IPsec implements no
AEAD cipher. Eight GCM spellings were rejected with
api.err.InvalidPayload while an otherwise identical body carrying
aes256 returned rc:ok, which isolates the enum rather than the
request as the cause. The accepted set is aes128/aes192/aes256/3des.
Both Anaheim tunnels terminate on UniFi gateways, so this blocks the
ESH tunnel on the same grounds.

Measuring while testing also retires the premise. NH3's uplink is a
1 Gbps link, so Anaheim's 2 Gbps circuit was never the relevant
ceiling, and the tunnel sustains 692 Mbit/s across 8 streams rather
than the ~550 previously recorded from a 4-stream run. Compared with
WireGuard over the same gateway and uplink, the deficit narrows from
2.3x at one stream to 15% at eight, so moving this link onto a
different transport is not worth doing.

The constraint is per-stream, around 245 Mbit/s, with both endpoints
idle under load. Parallelising bulk transfers remains the mitigation
and is worth 2.8x at no cost; NFS nconnect is the equivalent lever
for single-stream mounts such as /mnt/smithy on ana-ml2.

FortiGate phase2 for pfi-ana-nh3 keeps the widened proposal list,
which is inert while the peer offers only CBC and avoids a further
renegotiation. The UDM network object was diffed field-by-field
against its pre-change snapshot and is unchanged.
2026-08-23 14:29:17 -07:00
vh 5af362e9d0 ops(ana-gw): restore WAN admin access ahead of the FortiGate cutover
Re-open the ana-gw admin GUI on wan1 so the Anaheim edge can be
managed remotely if the cutover goes wrong, reversing part of the
2026-08-12 lockdown. Two config changes, nothing else (verified by
diffing pre/post `show full-configuration`):

- wan1 `allowaccess ping https` — https only; http, ssh, and fgfm
  stay off, and wan2 is untouched.
- `infra-ops` trusthost widened to all routable IPv4; the `admin`
  account stays locked to 10.0.0.0/8 so the guessable username
  remains unreachable from the internet.

Verified end-to-end from two sites: a real `/logincheck` POST returns
AUTH OK over the public path, on a browser-trusted Let's Encrypt cert
for ana-fw.phasefinal.com valid through 2026-10-27.

Two FortiOS behaviours worth recording, both of which cost time here:
a trusthost whose base address is 0.0.0.0 is silently treated as
unset (so there is no writable "any" — only decomposed ranges), and
trusthost is enforced before the TCP handshake, so a blocked source
sees a filtered port rather than a refused login.

Follow-ons captured in memory, not actioned: ACME renewal for the
admin cert needs port 80 on wan1 (next attempt ~2026-09-27), and a
~5 SYN/s source in 179.51.184.0/21 now draws SYN-ACKs at no
measurable CPU cost.
2026-08-23 14:03:40 -07:00
vh b6340519bc memory: snapshot — Anaheim tunnel at 25% of circuit; selene retired; hrafn CI fixed
Session captured for a context reset. Six new detail entries.

THE OPEN ITEM: Anaheim's IPsec tunnel delivers ~550 Mbit/s aggregate against a
circuit measured at 2,153 Mbit/s. Not WireGuard (it is IPsec on ana-gw), not
CPU (idle), not crypto exhaustion (NPU-offloaded), not the fibre. Both tunnels
negotiate aes256-sha1; AES-GCM proposed. Operator signalled authorization;
execution pending, untracked by operator choice.

Also recorded: selene retired after losing a head-to-head on its own job with
chat-judge moved to gen and the model name left to 404; the 7-alias collision
on the gen seat that makes cross-alias corroboration an echo; hrafn adopted and
its CI found to have been reporting green while deploying nothing for its whole
life; all three Worldtree instances de-armed from a 69-day-stale :latest and
the Matrix homeserver re-plumbed to personal; every secret-bearing .env on
ana-docker tightened to 0600; the pfi org closing the repo-creation half of the
credential-migration directive; booth kept-board deletion and link pruning.

Two entries under Tried and abandoned: the CI checkout assertion that broke the
pipeline twice and was removed, and my proposal to alias a retired model name
at a different model, which the operator correctly overruled.

Index 271 -> 282 lines, under the 300 cap, so no archival fired.
2026-08-23 13:35:48 -07:00
vh 0ad332bb4a feat(booth): per-row link removal + render the link board as real UI
The standing link board is the one MULTI-WRITER booth -- every agent session
appends operator-facing URLs to it. "Delete the folder" was the only
granularity available, so removing one dead link meant hand-editing markdown.
It is 32 rows and only grows.

  booth links                 row number, entry id, raw row
  booth unlink 3              by row number
  booth unlink 8b40e0a5       by entry id (what the UI's x posts)
  POST /b/<name>/unlink       form field `entry` = content id

ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only
and multi-writer: another session can post between listing it and clicking x,
and an index would then delete a neighbour. An id either matches the row you
saw or matches nothing. A row number typed at the CLI is resolved to its id
BEFORE anything is deleted. Appends and prunes now take the same flock on
.links.lock, so a post cannot be lost inside a prune's read-modify-write.

UI: a booth carrying links.md renders as rows -- description, URL, provenance,
copy button, per-row x -- instead of a markdown blob. links.md is filtered out
of the gallery so it does not appear twice; the header counts LINKS not files;
the empty-state and the one-click "Wipe now" both stand down for a board (same
rule as the kept lane: nothing durable is one click from gone).

booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not
require the service venv -- importing app.py drags in FastAPI, so deleting a
line from a text file would have needed a web framework installed.

THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was
correct throughout -- module-only tests would have caught none of them:

- `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1
  whenever the board had rows. `unlink`'s index lookup calls it inside $( )
  under `set -e`, so a successful listing killed the caller and the removal
  silently did nothing while reporting success.
- ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read
  as row numbers, resolved to nothing, and removed nothing. Now disambiguated
  by the id's actual shape, not by "is it numeric".
- filtering links.md out of the gallery left `items` empty, so a full board
  rendered "This booth is empty" and an empty <div class="gallery"> under 32
  visible rows.

87 tests (was 76): parser tolerance of hand-written prose, content-id
stability across concurrent appends, removal precision, UI branch behaviour
for board/normal/empty booths, and subprocess CLI tests pinning the two shell
bugs. Deployed to nh3-dev and verified against the live 32-row board
read-only; board file byte-identical afterwards.
2026-08-23 12:55:33 -07:00
vh 4be880f36c feat(booth): kept boards can be deleted from the UI; document the TTL-reset trap
Kept boards had no delete path in the UI at all. The kept lane deliberately
omits the wipe control -- a one-click wipe next to the durable stuff is a
footgun -- but "deliberate" had been implemented as "impossible": the only
routes out were ssh or a hand-written API call.

Now it is two deliberate acts. A `release` control on kept cards drops the
sentinel, the board moves to the ephemeral lane, and the existing x wipes it
from there. Release is reversible -- POST /b/<name>/keep pins it again.

  POST /b/<name>/unkeep   release the pin
  POST /b/<name>/keep     pin it (round-trip, so release is not a one-way door)

FOUND WHILE TESTING, and it invalidates the previously-documented workaround:
removing the sentinel BUMPS the booth directory's mtime, and booth age is the
newest mtime in the tree -- so a released board's clock RESETS from 10,000s to
0s and it survives another full TTL. The old comment said "remove the sentinel
first (it rejoins the sweep)", which is true but means the board lives another
24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete.

test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so
nobody re-derives the workaround. Release is what unlocks the x; the x is what
deletes.

CLI: `booth rm` already worked on kept boards but said nothing about it. It now
announces "(was KEPT -- durable board)" so wiping something durable can never
look identical to wiping run output. Not a block -- a CLI user naming a booth
is being explicit.

5 new tests (67 pass). Verified live on nh3-dev: release renders on all four
kept boards, the ephemeral lane keeps its x, and the links board is untouched
with its sentinel intact.
2026-08-23 10:42:32 -07:00
vh b8a535507a feat(judge-bench): keep the judge harness; warn about the 7-way alias collision
Operator: keep the benchmark. It has a named second use (brokkr-smithy-dev
wants gen vs a trained reward model once their tournament converges) and a
demonstrated first one -- it caught a seat that had been coin-flip-grade for
five weeks with nobody measuring it.

Harness promoted from scratch to tools/judge-bench/:
- paths de-hardcoded; runs from its own directory
- proper CLI: --models (REQUIRED), --repeats, --limit, --gateway.
  Required on purpose: a stale default would silently benchmark a retired
  seat, and the original default (selene-1-mini-8b) now 400s.
- README states the limitation rather than burying it: 24 items of the
  author's own design, a screen and not a verdict. This harness scored the
  same pair 83 vs 96 while brokkr's corpus ranking task scored it 47 (chance)
  vs 94. Both honest; absolute scoring on designed items is an easier task
  than ranking real text.
- records brokkr's technique, which is better than anything here: a control
  constructed so the correct answer is DEFINITIONAL rather than judged cannot
  inherit the designer's error (item vs itself, response vs its own
  truncation, text vs its own clauses permuted). Add those before adding more
  judged items.

Gateway: comment-only warning at the head of model_list. SEVEN aliases now
resolve to the same weights (chat-judge, classifier, gen, image-judge,
qwen-image-bench, summarizer, summarizer-large -> qwen3.8-27b-uncensored).
That is intended under ADR-0012, but it has a sharp edge brokkr flagged:
cross-checking a result against another alias measures NOTHING when they are
the same model -- agreement is an echo, not corroboration. The note names the
other current collisions (glm-5.2 x4, TTS x4, reranker x2), gives the
/model/info one-liner to check, and records that probes should resolve alias
-> backing at run start AND end because the response `model` field returns the
alias, so a swap is otherwise invisible.

Verified: config still parses, diff is comment-only, canonical re-synced.
2026-08-23 05:16:10 -07:00
vh ca3c984f93 feat(selene): retire the seat; chat-judge -> gen, selene-1-mini-8b 404s by design
Benchmarked selene against gen on selene's own job: 24 designed judge items
with checkable ground truth, pairwise + absolute modes, 3 repeats, on BOTH a
neutral JSON prompt and Selene's native Atla template. 288 calls, all free
local.

  neutral JSON     selene 20/24 (83%)   gen 23/24 (96%)
  native Atla      selene 21/24 (88%)   gen 22/24 (92%)

gen won on both templates and selene's BEST sat below gen's WORST. Selene was
given its own fine-tuned template as a fairness check; it gained one point,
not the three it needed.

Decisive defect: selene cannot emit "tie" -- 0/2 on both templates, forcing a
winner on every equivalent pair. For eval work that is the case that matters
most. gen returned tie correctly on the JSON template. Selene also compressed
the 1-5 scale (clustered at 2s and 4s) where gen used it fully. Selene's only
win was ~3x latency, unexercised at ~60 calls/day with zero queueing.

TWO NAMES, TWO DIFFERENT TREATMENTS, deliberately:

- chat-judge -> repointed to gen. It is a ROLE alias and ADR-0012 says
  consumers bind the capability, not a concrete model. Sampler profile copied
  from image-judge (temp 0, top_p 1.0, top_k 1, thinking off) so the served
  config matches the benchmarked condition.

- selene-1-mini-8b -> REMOVED. It 404s. It was NOT aliased to gen. A
  served-name is a contract about what the model IS; answering it with a
  different model hides a material change behind a stable string. Operator
  ruling: "never repoint a named model at a different model's endpoint --
  that is intentionally misleading." Verified: the gateway now returns
  HTTP 400 "Invalid model name" for it.

Reclaimed 17.2 GiB on ana-ml2 GPU 1 (free 1,818 -> 19,450 MiB) on a card that
had under 2 GiB of headroom. gen already runs on GPU 0, so the judge role
moved onto an existing seat rather than allocating anything new.

Canonical litellm config synced from the host; ana-ml2 README and
recommended-model-settings updated. compose.yaml kept for reference, not
deployed.
2026-08-23 05:07:01 -07:00
vh a896c0a5a9 feat(playbooks): sweep world-readable secret-bearing .env files to 0600
Eight stacks on ana-docker kept secret-bearing compose .env files at mode
0644 -- readable by every local account on a box with four interactive
users. Verified as a real exposure, not a theoretical one, by reading one
as `nobody`. Six other stacks already used 0600, so this converges on the
existing house pattern rather than inventing one.

Swept (all verified mode 600, unreadable by nobody, compose config still
rendering as the deploy user, container untouched):

  vaultwarden  traefik  beszel  gitea-runner
  miniflux     news-digest  searxng  vor

Post-sweep the host has zero secret-bearing .env readable by nobody.

Checked before writing the playbook, and re-checked by the playbook itself
so a bad case cannot be swept in by accident:
- every target is owned by lkraven, the deploy user, so 0600 preserves the
  deploy path (asserted by rendering compose config AS lkraven, not root --
  checking the mode proves the bits changed, only rendering as the deploy
  user proves the next deploy still resolves its variables)
- none is bind-mounted into a container. vaultwarden looked like it was;
  that `- .env` is under `env_file:`, not `volumes:`. A genuinely
  bind-mounted .env is read by the container UID and 0600 could break it,
  so the playbook refuses that case.

Two gotchas recorded in the file:
- elway's variable regex eats any bare identifier in braces, so a docker
  --format Go template containing {{end}} or {{println}} fails as an
  undefined variable. Grep the raw inspect JSON instead.
- the `ana-docker` ssh alias resolves to lkraven, who needs a sudo
  password; run elway against infra-ops@10.250.50.70 for NOPASSWD.
2026-08-23 03:09:13 -07:00
vh 9642952a54 docs(hrafn-ci): mirror the now-canonical vh/hrafn pipeline; record two failed runs
claude-bot holds write on vh/hrafn as of 2026-08-23, so the canonical copy of
the pipeline moved there and infra-ops maintains it directly instead of
routing patches through the repo holder. The files here are a verified mirror
(byte-identical to live at 22e0eb9d75a6).

Live state: run 9922 green, and both new content assertions executed rather
than merely existing --

  verify 4/5  host tree matches shipped context (a99ce748a0c9...)
  verify 5/5  image source matches host source (06f209fd0641...)

The CI-computed context hash matching on the host is the end-to-end proof
that the converge lands what CI ships. Its absence is what let the
frozen-source bug survive every green deploy.

Also records why the HEAD == GITHUB_SHA assertion was added and then removed:
it needed the git binary (run 9920, exit 127), and installing git flipped
actions/checkout@v4 from its node implementation to the git binary, which
died on a missing CA bundle (run 9921). A nice-to-have assertion changed the
checkout code path and broke a working pipeline; it guarded a hypothesis that
proved wrong, so it went rather than getting ca-certificates bolted on.
2026-08-23 02:55:02 -07:00
vh b38c369313 fix(hrafn-ci): staging dir inside the rsync target froze host source silently
Root cause of nevermore-claude's report that v1.0.0 deployed green while the
host kept serving 0.1.0.

The staging dir was $compose_dir/.stage -- INSIDE the rsync target. So
`rsync -a --delete $compose_dir/.stage/ $compose_dir/` deleted .stage from
the destination (absent from the source listing) DURING the transfer,
destroying the source mid-copy. Reproduced exactly:

  before:  app.py="OLD"  leftover.txt  .stage/app.py="NEW"
  after:   app.py="OLD"  leftover.txt GONE, .stage GONE

Deletion worked; the copy silently did not. So the directory looked
converged while host source stayed frozen at the first manual rsync, and
because the build's COPY inputs never changed, Docker full-cache-hit and
every SHA tag aliased one image. The provenance guarantee was false.

Nothing caught it because the verify steps asserted the marker, health, and
a 200 from /readyz -- all of which pass on a frozen host. None measured
content.

Fixes:
- stage at /tmp/hrafn-deploy-stage, outside the target
- CI computes context_sha256 over the shipped file list; the playbook
  recomputes it on the host post-converge and fails on mismatch
- compare the running container's src/**/*.py against the host's, catching
  a SHA tag naming layers the image does not contain
- checkout clean:true + assert HEAD == GITHUB_SHA so a reused runner
  workspace fails the job rather than shipping a stale tree

Declined --no-cache: a cache hit is correct when the context is genuinely
unchanged, and the new assertions prove the property directly rather than
brute-forcing it.

The container-vs-host check compares only *.py -- `pip install .` generates
src/hrafn.egg-info/* inside the image and __pycache__ appears at runtime, so
a naive `find src -type f` compare false-fails on every healthy deploy.
Verified against the live container before shipping (12 host files, 18 in
container, 0 content differences).
2026-08-22 21:57:16 -07:00
vh bb19a96f39 fix(playbooks): compare image IDs, not the created-from tag; make the repin idempotent
Two defects the worldtree-pinned case exposed in the guard written an hour
ago.

1. The guard compared the container's .Config.Image STRING against the tag
   being pinned. That string is only the tag the container was CREATED from,
   which can differ from what it actually runs: worldtree-pinned was created
   from `:latest` back when that tag pointed at 446e5807, and `:latest` has
   since moved to b19afd71d7cc. So the guard refused an instance whose
   pinning was correct and necessary. Now it resolves the target tag to an
   image ID and compares that against the running image ID -- asserting the
   thing actually cared about, that this tag names the bytes now running.
   It also fails closed when no such local tag exists.

2. The sed step reported CHANGED unconditionally, so a re-run on an
   already-pinned instance claimed work it had not done. Gated behind a
   `when:` that skips when the line is already correct; a second run on demo
   now reports "2 ok, 0 changed, 2 skipped / overall: OK".

Applied to worldtree-pinned under worldtree-dev authorization. That instance
needed a `docker tag` first -- its image was DANGLING (no repo tags, kept
alive only by the running container), so the fleet's frozen reference was one
`docker rm` from garbage collection. Tagged as :446e5807bf43, then pinned.

All three instances now render a SHA with no floating tag anywhere:
  worldtree          -> :ae88a057c0ed
  worldtree-personal -> :f63529168c13
  worldtree-pinned   -> :446e5807bf43
Nothing restarted -- pinned still Up 3 months, its start time unchanged.
2026-08-22 21:52:20 -07:00
vh 064181a8fb feat(playbooks): re-pin Worldtree WORLDTREE_IMAGE off the floating :latest tag
Both corviduo-dev Worldtree instances carried
`WORLDTREE_IMAGE=.../worldtree:latest` in their .env while running
SHA-tagged images built the same day. The local :latest tag pointed at
b19afd71d7cc, built 2026-06-14 -- 69 days stale. Any `docker compose up`
on either instance, by anyone, silently downgraded that service by 69
days: the same footgun behind the 2026-06-15 outage.

Applied under worldtree-dev authorization (Worldtree #410):
  demo     -> gitea.phasefinal.com/vh/worldtree:ae88a057c0ed
  personal -> gitea.phasefinal.com/vh/worldtree:f63529168c13

Both runs verified compose config resolves every service to the pinned
SHA with no :latest remaining, and that no container restarted. The edit
is inert by design -- it changes what the NEXT recreate pulls.

The playbook guards against pinning a SHA that is not the one actually
running, which would re-arm the same hazard with a different image.

Also records the worldtree-pinned case, deliberately NOT changed here:
that instance runs a DANGLING image (sha256:446e5807, no repo tags),
kept alive only by the running container. It has no tag to pin to, so it
needs `docker tag` first -- and an untagged image referenced only by a
container is one `docker rm` from garbage collection, which for the
fleet's frozen reference instance is worth fixing on its own merits.
Out of the authorized scope (demo + personal); raised with worldtree-dev.

Stopgap: the durable fix is the deploy workflow stamping the deployed SHA
into .env at each deploy, queued repo-side with worldtree-dev.
2026-08-22 21:49:42 -07:00
vh 11b9d1891e fix(hrafn-ci): make the deploy converge instead of accrete
The first CI run shipped clean but revealed a design gap in the playbook:
unpacking the context tarball in place overwrites tracked files and never
removes anything. Leftovers from the pre-CI hand-rsync (tests/, docs/,
ROADMAP.md, persistent-memory.md, CLAUDE.md, LICENSE) survived the deploy
and had to be cleaned off ana-docker by hand.

That is the same failure class that produced the mess in the first place:
a deploy that only ever adds cannot return the host to a known state.

- unpack to a staging dir, then rsync --delete onto the compose dir
- protect host-owned .env and .deployed by name
- add .env.example to the context tarball so converge does not delete it
- record in the workflow that the tar list is now AUTHORITATIVE: anything
  omitted is removed from the host on the next deploy

Re-validated with `elway --dry-run` (9 steps, 3 verify, parses clean).
Not yet in vh/hrafn -- infra-ops has no write access there, so this is
offered to the repo holder rather than pushed.
2026-08-22 15:31:50 -07:00
vh b001d0cb2e feat(hrafn): author the CI deploy that replaces the hand-rsync path
hrafn was handed to infra-ops for uptime ownership with no CI deploy and
no commit provenance -- the image was always local/hrafn:v1 and the whole
working tree lived in the compose directory.

These two files fix both. They are authored here because infra-ops owns
hrafn's uptime, but they belong in vh/hrafn; claude-bot is not a
collaborator there, so they are handed to the repo holder rather than
committed directly.

- playbooks-deploy.yaml -> vh/hrafn playbooks/deploy.yaml
- gitea-workflows-deploy.yaml -> vh/hrafn .gitea/workflows/deploy.yaml

Design calls recorded in the README: the build context travels as one
tarball rather than per-file upload steps (nevermore's pattern fails open
when a new source file has no matching step), and the playbook refuses to
deploy unless .env exists at 0600 -- a guard prompted by it arriving 0644
with a live bearer token in it.

Validated with `elway --dry-run`, which caught a real interpolation bug
during authoring. No new Actions secrets needed.
2026-08-22 15:26:45 -07:00
vh b6924de728 feat(hrafn): register the browser-fetch service and tighten its env perms
nevermore-claude handed hrafn to infra-ops for uptime ownership. Intake:
verified health independently, brought it into the inventory, and fixed one
security defect found during the check.

- canonicalize stacks/hrafn/ (compose.yaml + .env.example + README)
- list hrafn in the ana-docker running-stacks table
- README records the load-bearing bits: shm_size 1gb is required for
  Chromium, playwright and the base image are version-coupled, SSRF denies
  private targets by default, and restart:unless-stopped does NOT act on
  healthcheck failure

Security fix applied on the host, not in this tree: /opt/docker/compose/
hrafn/.env was 0644 with a live 57-char bearer token in it, readable by
every local account (verified by reading it as `nobody`). Tightened to 0600;
owner unchanged so the deploy path still works, confirmed via `docker
compose config` as lkraven. Container untouched and still healthy.

Known gaps recorded rather than silently accepted: no CI deploy, and the
image builds from an rsync'd working tree living in the compose directory,
so the running image has no commit provenance.
2026-08-22 15:05:11 -07:00
vh 7bf17dd39e fix(filezilla): set restart: unless-stopped and canonicalize the stack
The filezilla container on vm-esh-nas shipped with no restart policy. It
was SIGTERMed during the 2026-08-18 host shutdown and never came back,
staying down four days while dockge/dozzle-agent/beszel-agent all
recovered on their own.

- add `restart: unless-stopped` (applied on the host, container recreated
  and verified serving 200 on :5800 with all four /NAS mounts visible)
- promote the previously un-canonicalized compose into stacks/filezilla/
  so the policy is version-controlled rather than host-only
- record the missing-restart-policy failure mode in the host README
2026-08-22 09:50:51 -07:00
vh 837fa362fc memory: snapshot — sec rebuilt on DFlash2 + newer vLLM; quant tokenizer defect fixed fleet-wide
Rewrites the in-flight section (140 lines to 18) now that the AI-seat work
has settled, promoting the durable calls into the dated decision log where
they will not decay: DFlash2 measured and promoted to sec's compose stack,
the calibration-truncation defect fixed at source in the quant pipeline,
sec retuned after a runtime OOM, the Max-Q spread disproved by telemetry,
ESH dual-stack with the Cityside static proven unprovisioned, and speaches
live with the weak-gate finding.

Carries forward the two things a fresh session must not get wrong: the sec
degeneration question is confounded because engine and drafter changed
together, and neither the 2k nor the 10k observation is evidence.

Auto-archived one entry (Recent decisions: 1) to archival-memory.md.
Eight further candidates were held back by the guards -- open deferred work
or referenced by in-flight state.
2026-08-22 09:46:11 -07:00
vh 6e82899ba7 fix(mog-sec): retune to 0.52/420K after a runtime OOM at 0.55/480K
The 0.55 configuration booted cleanly, passed every startup check, and then
killed the engine on an ordinary request of roughly 20k tokens:
torch.OutOfMemoryError allocating 614 MiB in the GDN causal_conv1d path with
443 MiB free. The container self-recovered via restart: unless-stopped.

Context length was not the cause. gpu-memory-utilization is not a hard
reservation. Weights and KV are reserved up front, but activation memory is
allocated on demand and the profile that sizes it uses dummy data at
max_num_batched_tokens. The profile predicted 3.62 GiB; the process sat about
1 GiB over its entire budget even at idle. With six vLLM containers sharing
GPU1 there was no slack left to grow into, and a modest allocation lost the
race. 0.52 leaves roughly 7.7 GiB free against 4.6 GiB at 0.55.

Records a measurement that governs how max-model-len can be chosen at all:
the KV pool varies between boots. Two consecutive boots at 0.52 produced
425,663 and 453,600 tokens, a 6.6 percent swing, because the pool is sized
from free memory at startup and that depends on what the other tenants hold
at that instant. 420,000 sits under the lower observation so the seat starts
in both conditions. Sizing to the higher one would boot today and refuse
tomorrow, which is why the value was measured across boots rather than
estimated from one.

Concurrency of about 1.0 to 1.08x means one request at full length. Shorter
requests still batch normally, since vLLM schedules by tokens rather than by
max-model-len.
2026-08-22 02:03:24 -07:00
vh 8389470898 feat(mog-sec): promote the DFlash2 configuration into the compose stack
Operator approved after real-use testing. The experimental standalone
container is retired and stacks/mog-sec is canonical again, with
restart: unless-stopped so the configuration survives a reboot.

Cutover verified against the container it replaces: KV pool 526,617 tokens
at 1.10x concurrency, identical; zero restarts; both gateway aliases
serving; DFlash2 confirmed drafting at k=7 with 231 draft tokens over 33
drafts; vision working at 2048x2048.

One variable was deliberately dropped rather than carried over. The previous
stack hardcoded PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, the
validated container never set it, and the quant playbook records
expandable_segments corrupting retained tensors in another context. The
compose now defaults it empty via MOG_ALLOC_CONF. Promoting the stack as it
stood would have shipped a variable the tested configuration did not have.

The speculative config moves into a single MOG_SPEC_CONFIG carrying the
whole JSON, because the two shapes are not interchangeable: dflash requires
a model pointing at the drafter and MTP must not have one, so a
method-plus-tokens template cannot express both. Also parameterised:
MOG_DRAFT_MODEL, MOG_MM_PROCESSOR_KWARGS, MOG_MAX_NUM_BATCHED_TOKENS.

The mm-processor image cap is now mandatory rather than incidental. The
model's own preprocessor declares 4096x4096, which expands to 16384 image
tokens and kills startup on builds that enforce the image-token count check.

Adds the .env.example this stack never had, carrying the measured rationale
for each value and the one-line rollback.
2026-08-22 01:16:27 -07:00
vh 20ac53052b memory: retract the MTP-head degeneration hypothesis; n=1 was never evidence
Operator ruling: the multi-turn degeneration lives in the un-fixed vLLM, not
in the weights. The hypothesis that sec's stock-graft MTP head causes it is
withdrawn.

Two failures produced it. First, a false dichotomy treated as a deduction:
having verified gen and sec run an identical engine, I concluded config was
eliminated and therefore the weights were responsible. That does not follow.
An engine bug present in both seats is not exonerated by the seats being
identical; it only means the engine cannot explain a difference between
them. It can still explain the failure.

Second, and more instructive, the difference being explained may not exist.
The premise was a single operator observation made during a session with
many concurrent changes. That cannot carry a causal claim, and it became the
load-bearing support for a root-cause narrative it could not hold.

The same caveat now attaches to the coherent-to-10k observation on the new
build: same n, same uncontrolled conditions, opposite direction. The
comparison is weak at both ends, so the file no longer presents either
sighting as a result.

What survives as measured fact is unchanged and still recorded: sec's MTP
head is byte-identical to the uncensored base across all 15 tensors, gen's
was abliterated in-band, and acceptance differs slightly. None of that is
shown to cause degeneration.

Adds the generalisable lesson: an observation made while many things are
changing cannot support a causal conclusion. It is the inverse of the
warning already in the gen-seat compose file, which guards against trusting
a negative result from a synthetic probe; this guards against trusting a
positive sighting from an uncontrolled session.
2026-08-22 00:58:35 -07:00
vh ab3a0ca5bc docs(quant-playbook): acceptance is not throughput -- always run the depth control
Measured 2026-08-22 on one target with one instrument: raising MTP
num_speculative_tokens from 3 to 7 improved accepted length from 2.753 to
3.041 per forward pass while throughput fell from 114.9 to 74.0 tok/s.
Reporting acceptance alone would have recommended a 36% regression.

The cause is architectural rather than model-specific. A single-module MTP
head has no depth of its own, so vLLM runs it autoregressively and k draft
tokens cost k sequential forward passes. Past a shallow depth the drafting
cost exceeds what the extra accepted tokens save.

Records the comparison rule that follows: match k when comparing two
speculative methods, or the measurement is of depth rather than method. A
parallel-drafting drafter at k=7 against an autoregressive MTP at k=3 is not
a method comparison. In the case that produced this, the depth control
showed most of the apparent acceptance advantage was depth, while the
throughput advantage was real and came from parallel drafting -- our MTP was
better at position 0 and still lost overall.

Only the measured, model-agnostic result is recorded here. The
DFlash2-specific findings, the hypotheses that remain unproven, and the
wrong turns taken along the way live in
persistent-memory.d/2026-08-22-dflash2-spec-decode.md with explicit
epistemic labels, deliberately kept out of the playbook.
2026-08-22 00:52:06 -07:00
vh 9f87b7c4e5 memory: DFlash2 spec-decode measured; sec running on it (experimental, confounded)
Records the 2026-08-22 session with measured results, hypotheses, and wrong
turns explicitly separated -- the operator held this back while it was in
flight specifically so conjecture would not enter the record as fact.

MEASURED. DFlash2 works on an abliterated + NVFP4 compressed-tensors target
on Blackwell sm_120, which the model card does not claim (it tests stock
BF16 on H200). gen 2.753 -> 3.254 accepted tok/forward and 114.9 -> 131.9
tok/s; sec 2.676 -> 3.252 and 110.5 -> 130.0. The drafter is model-agnostic
across two different finetunes to 0.06%, but is EAGLE3-style coupled to its
target's hidden states, so the weights file is shareable while the 3.85 GB
of VRAM is per-seat.

The k=7 MTP control is the load-bearing result: raising MTP depth improves
acceptance and collapses throughput to 74.0 tok/s, because a single-module
head run autoregressively costs one forward pass per draft token. Without
that control the obvious recommendation would have been wrong.

CONFOUNDED. sec no longer degenerates at 2k, but the engine advanced 259
commits and the drafter changed at the same time. Isolating it means running
MTP k=3 on the new build. Also recorded: #51113 is present in both builds
and is therefore necessary but insufficient, since sec ran it and still
degenerated.

HYPOTHESES, labelled as such: that sec's stock-graft MTP head causes the
degeneration, and that NVFP4 explains the gap against published acceptance
figures. Neither is proven.

WRONG TURNS, recorded so they are not re-derived: version strings are not
lineage, Docker Hub push timestamps are not source freshness, and the claim
that 1M context needs YaRN absent from config is false for the sec quant.

Operationally important: sec is serving from a standalone container rather
than its compose stack, which is stopped but unmodified. Rollback is two
commands and is written down.
2026-08-22 00:51:28 -07:00
vh 0755ba7d00 fix(quant): stop baking the calibration truncation cap into the shipped tokenizer
load_calib tokenizes with tok(..., truncation=True, max_length=seqlen). For a
fast tokenizer that mutates the Rust backend's truncation state in place, and
the subsequent tok.save_pretrained() persisted it, so every mixed-NVFP4 build
shipped a tokenizer.json carrying

  "truncation": {"direction": "Right", "max_length": 2048, ...}

against a source whose value is null. Every prompt was clamped at the
calibration length, permanently.

It hid because older transformers does not enforce the text-vs-ids count
check. On a newer one the seat dies at startup with a message that names
images and never mentions tokenizers:

  ValueError: Mismatch in `image` token count between text and `input_ids`.
  Got ids=[2047] and text=[16384].

The cap also silently limited image resolution well before it killed
anything -- at 2048 the largest servable image is about 1448x1448, since
(edge/patch)^2 / merge^2 image tokens have to fit under it.

Fix saves a pristine tokenizer re-read from the source rather than the
mutated calibration object, and then asserts truncation is null so the
defect fails the build instead of shipping again.

Playbook gains section 3.14 with the symptom, the cause, the audit one-liner
and a table of which builds were affected, plus a fourth mandatory post-step.
The transferable lesson is called out: this is the third case of an artifact
carrying config authored against an older transformers that a newer one
begins enforcing, so an image bump is a config-compatibility event rather
than just a version change.
2026-08-22 00:32:21 -07:00
vh ad21302474 memory: ESH internal IPv6 live on two LANs; Cityside v4 static proven unprovisioned
Operator-directed IPv6 enablement on ESH-UDMPM: esh-userland (VLAN 10, SSID
PVC) and esh-server flipped from ipv6_interface_type=none to pd against the
delegated 2607:73c0:402:1d00::/56. Verified esh-docker-vm egresses over v6 as
its own address with no NAT, against v4 which still egresses as the shared
CGNAT pool. No disruption through either write; originals backed up.

NH3 stays v6-off deliberately -- its single /64 is reserved for network
meshing, so nh3-dev having no v6 egress is correct rather than a fault.

The v4 static is NOT provisioned, and this is now proven rather than
inferred. A full gateway reboot -- verified by observed down-transition and
an uptime reset from 140,846s to 183s -- forced a fresh DHCP DISCOVER and
Cityside returned the identical address. Recorded alongside it: a DHCP renew
could never have surfaced a new address in the first place, since renewal
preserves the lease by design. Next step is a carrier ticket, not more
testing.

The v6 firewall posture was audited and is correct -- default-deny inbound
for both IP versions, only four hand-written policies, none WAN-facing. My
earlier warning that v6 removes CGNAT's accidental shielding was overstated
and is retracted in the record. The audit's limitation is stated: it read
rules, it never probed from outside, because no v6 vantage exists.

Flat-zone lateral-movement finding parked as id 44 rather than actioned --
operator's call, moot until the mesh and segmentation design settle.
2026-08-21 22:01:08 -07:00
vh c7e21879ae memory: speaches ASR live on irv-ml1 — and no_speech_prob is a weak gate
Records the deployment plus the finding that outlives it: no_speech_prob
alone does not carry a hallucination threshold on this stack. Silence and
room tone both hallucinated 'Thank you.' at no_speech_prob under 0.11,
where a conventional >0.6 gate passes them through; avg_logprob separates
~6x better. Any future consumer asking for a Whisper hallucination gate
should be pointed at the composite, not the single field.

Also banks the digest-pin rationale (the VAD flag is an upstream-declared
unstable variable name, so a floating tag can move gate semantics
silently), the two deployment gotchas, the docker-vs-native GPU index
inversion on this host, and Eyra's future diarization VRAM claim on the
same A6000.

Adds speaches to the irv-ml1 stack table alongside parakeet, with the
reason both exist.
2026-08-21 14:33:08 -07:00
vh aa5863c9a3 feat(speaches): OpenAI-compatible faster-whisper ASR seat on irv-ml1 A6000
Deployed for Eyra (meeting recorder) per the eyra-dev request. Serves
large-v3 (batch tier) + distil-large-v3 (low-latency tier) on :8204,
fp16, both resident, ~5.9 GB VRAM against 20 GB still free.

Sits alongside the existing parakeet stack (:8765) deliberately: parakeet
is a TDT/transducer returning bare {"text": ...} and has no no_speech_prob
concept, so it structurally cannot serve this consumer.

The load-bearing requirement -- segments[].no_speech_prob surviving
response_format=verbose_json -- is VERIFIED on both tiers.

Measured finding worth more than the deployment: no_speech_prob alone is a
WEAK hallucination gate on this stack. Pure silence and pink room tone both
produced the classic Whisper 'Thank you.' hallucination while no_speech_prob
stayed under 0.11 -- a conventional >0.6 threshold passes both through.
avg_logprob separates the same cases ~6x more decisively (-0.11 speech vs
-0.65/-0.72 non-speech) and compression_ratio splits 1.141 vs 0.556.
Consumers should gate on a composite, not no_speech_prob alone. Table in
the README.

VAD pinned OFF at the consumer's request (they VAD-gate upstream on the
capture edge). Consequence stated plainly in the README: with VAD off this
service will transcribe silence into text and is not defending itself.

Image pinned BY DIGEST rather than :latest-cuda, because the VAD-off
setting rides on _UNSTABLE_VAD_FILTER -- a variable upstream explicitly
marks unstable. A floating tag could rename it on any bump, silently
restoring VAD and moving no_speech_prob semantics under a calibrated gate
with no error and no log line.

Two deployment gotchas recorded: PRELOAD_MODELS only loads models already
cached (it does not download -- use POST /v1/models/{id}), and the bind-
mounted cache needs a hub/ subdir or every /v1/models call 500s with
CacheNotFound while /health still returns 200.
2026-08-21 14:31:57 -07:00
vh ff5ce212da memory: OWUI credential state — password reset, admin-scoped API key vaulted (operator-ruled)
Two OWUI credential facts worth surviving a context reset:

  - The reported lockout was NOT a broken account. The vaulted password returned
    200 from POST /api/v1/auths/signin immediately before the reset, so the cause
    was client-side. Test the vaulted credential against signin before concluding
    an account is broken -- this is the second lockout here traced to an assumed
    rather than verified credential.
  - The operator-minted API key is ADMIN-scoped, inherited from the owning user,
    and that is the INTENDED shape per operator ruling: infra-ops manages the
    instance, so the key needs the rights the management work requires. A
    least-privilege dedicated-user split was offered and declined. Do not
    re-propose it.

Both credentials vaulted at esh-docker-vm/open-webui-{admin,api-key}; key verified
against /api/v1/auths/ (admin identity) and /api/models (33 models), with an
unauthenticated 401 control. Plaintext copies shredded.

Also folds in an unadjudicated drift found in passing: the running image is the
floating ghcr.io/open-webui/open-webui:main, not the v0.11.0 the stack notes
claim. Replaces the now-resolved 'operator's OWUI password change' follow-up.
2026-08-21 14:14:44 -07:00
vh b8e5022a1a memory: snapshot — three AI seats settled (orcarouter gen / MeroMero dual-mode RP / sec pen-test), Open WebUI deployed 2026-08-21 13:55:35 -07:00
vh 5e47a59b32 feat(meromero): MeroMero-v2 dual-mode (prose + streaming CoT) live on one seat — no re-quant
The multi-turn Gemma-4 CoT problem is solved. One MeroMero-v2 seat, one weight set,
two aliases: char-rp (prose) + char-rp-reasoning (streaming chain-of-thought).

The winning stack, traced from vLLM source by the four-arm brokkr/dwarf panel:

  - vllm/vllm-openai:v0.26.0 — ships transformers 5.14.1 natively, below the
    head_dim guard, so Gemma-4-31B loads with no pin and no custom image. It also
    carries the #48217 streaming pre-arm fix.
  - A patched chat template whose enable_thinking:true branch force-opens a BARE
    <|channel> (not <|channel>thought\n -- full-open defeats _preprocess_feed's
    injection). --chat-template override, no re-quant.
  - Two served-names char-rp / char-rp-thinking; --reasoning-parser gemma4;
    default enable_thinking:false. LiteLLM char-rp -> prose, char-rp-reasoning ->
    the thinking served-name with enable_thinking:true.

Verified: streaming CoT split 6/6 direct on :8016 and 3/3 through the gateway;
char-rp prose clean on both transports with no trailing-token leak.

Two hard-won facts recorded in persistent-memory:
  - STREAMING ONLY. Non-streaming can't split -- extract_reasoning never receives
    prompt_token_ids so the pre-arm can't fire (a vLLM one-shot bug unchanged
    across v0.24-0.27). Fine here: Lobe/OWUI stream. Upstream PR #49797 fixes
    non-streaming too, landing ~v0.28.0 -- then it's a clean image bump.
  - KEY-NAME TRAP: vLLM streams reasoning in delta.reasoning; LiteLLM normalizes
    to delta.reasoning_content. I lost two false-negative test rounds to this.

Canonical: stacks/meromero-charrp/ (compose + patched_chat_template.jinja) and
stacks/litellm/conf/config.yaml. Rollback is the .env image line + dropping
--chat-template.
2026-08-21 13:03:53 -07:00
vh 76834777a4 feat(open-webui): deploy env-declarative chat bake-off on esh-docker-vm:3211
Stand up Open WebUI v0.11.0 parallel to lobe-chat (:3210) as an
operator-approved candidate replacement. Env-declarative config
(ENABLE_PERSISTENT_CONFIG=False = the deploy is the config source of
truth), model picker auto-tracks the LiteLLM gateway with no pins,
background task model pinned to summarizer, TTS routed direct at the
dots gateway (:8198).

Gates verified on the box: G1 (declarative config, both directions),
G2 (picker == live gateway roster, no hand-listing), G3 (models/sync
genuinely reconciles create+delete), G5 (task model pinned). G4 (TTS)
handed to tts-dev.

Credential posture (G6): fresh capped LiteLLM key open-webui-esh
(all-proxy-models, $50/1mo budget) rather than inheriting the uncapped
lobe-chat-esh key; signup locked off after admin creation. Secrets
vaulted under esh-docker-vm/open-webui-*.

Note: the API-key toggle env var is ENABLE_API_KEYS (plural); a var
only reaches the container if compose names it in environment:.
lobe-chat is untouched.
2026-08-21 09:52:07 -07:00
vh f01ee28cea memory: gemma4 CoT test on v0.27.1 blocked by config incompatibility (reverted)
Tried serving the MeroMero NVFP4A16 quant on vllm/vllm-openai:v0.27.1 to test the
per-request enable_thinking hypothesis. Couldn't even load it:

  - v0.27.1's stricter transformers raised AmbiguousGlobalPerLayerAttributeError
    on the Gemma-4 config's head_dim (now a per-layer attribute).
  - Setting allow_global_per_layer_attribute_access on text_config downgraded that
    to a warning, but then gemma4.py load_weights asserted:
    "Attempted to load weight (512) into parameter (256)". Gemma-4-31B is
    genuinely heterogeneous -- some layers have head_dim 512, not a uniform 256 --
    so forcing the global value built wrong-shaped parameters. The guard was right.

So the MeroMero quant's config was authored for v0.24.0's Gemma4 loader and cannot
load on v0.27.x without a config migration or a re-quant against newer transformers.
The per-request-kwargs hypothesis was never reachable -- the model wouldn't load.

Consequence worth carrying: the eventual gen-seat move to v0.27.2 stable must
re-verify config-compat for any Gemma-4 seat of this vintage.

Fully reverted: config.json restored (flags removed), image back to latest
(v0.24.0), gateway char-rp-reasoning removed, char-rp prose verified. char-rp stays
on v0.24.0; MeroMero CoT remains undelivered. For RP-with-CoT use gen-reasoning, or
re-quant MeroMero against v0.27.x (real work, unproven payoff).
2026-08-21 09:21:25 -07:00
vh 7ebbcec5bb memory: vLLM release status for #51113 — no stable yet; gemma4 CoT is decoupled
Checked upstream. Latest stable is v0.27.1 (Aug 11) and it does NOT contain
#51113 -- the v0.27.0..v0.27.1 compare is only "DSpark Markov heads" plus three
CI commits. The #51113 cherry-pick (khluu, Aug 9, "v0.27.0 cherry picks"
milestone) is staged on the release-0.27 branch but no v0.27.2 stable has been
cut; only v0.27.2rc1 / the pinned nightly carries the fix. So the standing
follow-up (move gen off the nightly) still waits on a v0.27.2 stable cut.

Key decoupling: #51113 fixes the Qwen3.8 MTP + prefix-caching accuracy drop
(#43559). It is irrelevant to Gemma-4 seats, which have no MTP head. So the
meromero/gemma4 CoT test does NOT need the nightly and does NOT need #51113 -- it
can run on the latest stable v0.27.1, which is newer than the seat's current
v0.24.0 and where per-request chat_template_kwargs is expected to work.
2026-08-21 08:37:06 -07:00
vh b84ad888d6 memory: CORRECT the MeroMero-CoT finding — not a hard wall, not MeroMero-specific
My prior entry claimed the gemma4 reasoning parser is process-wide and the CoT
was architecturally blocked. Reading the actual code proves that wrong:

  - Template (chat_template.jinja:347-352): thinking is a PER-REQUEST toggle.
    enable_thinking:false prefills an empty <|channel>thought<channel|> to
    suppress; true omits the prefill so the model can emit a real thought channel.
  - Parser (vllm/reasoning/gemma4_utils.py): splits on <|channel>/<channel|> tag
    presence, "works with or without enable_thinking" -- NOT a process-wide flag.
    The stale compose comment I trusted cited an old parser API (vllm/parser/
    gemma4.py:439) this container doesn't run.

So the mechanism is gemma4-GENERAL (any gemma4 finetune on this template family),
and the two-served-name gen pattern should work. What actually failed the test was
that meromero runs vllm/vllm-openai:latest (v0.24.0), where per-request
chat_template_kwargs.enable_thinking didn't take effect -- whereas the gen seat's
pinned nightly demonstrably applies it. That's a version/plumbing issue, not the
model and not the architecture, and it would hit any gemma4 finetune on that image.

Unverified fix (needs a GPU window): re-serve meromero on the nightly image with no
process default and per-request enable_thinking. Currently reverted to known-good.
2026-08-21 08:25:02 -07:00
vh a260b57974 memory: MeroMero-v2 CoT via vLLM gemma4 parser is not achievable (tried + reverted)
Operator asked to serve MeroMero (char-rp) with reasoning as char-rp-reasoning.
The model genuinely reasons (GRPO-trained with thinking; ships Think/NoThink
SillyTavern presets), but two facts kill the vLLM path:

  - it's byte-identical to the weights we already serve (same zerofata repo,
    chat_template sha 94899c0f matches our on-disk quant), so re-quanting adds
    nothing; and
  - the gemma4 reasoning parser's enable_thinking is PROCESS-WIDE, not per-request.
    The seat's --default-chat-template-kwargs sets the parser state machine; a
    per-request chat_template_kwargs.enable_thinking reaches the template but not
    the parser (unlike the qwen3 parser the gen seat uses). So the two-served-name
    gen/gen-reasoning trick can't work.

Verified empirically: added a char-rp-thinking served-name and removed the process
default; enable_thinking:true per request still produced pure prose, 0
reasoning_content. Per the seat's own comment, a dedicated thinking-on process
dumps all prose into reasoning_content with null content -- no clean split.
MeroMero reasons only client-side in SillyTavern, not through vLLM.

Fully reverted to known-good: single served-name, process default restored,
char-rp prose verified end-to-end. GPU0 both seats healthy, gen untouched.

Kept one change: MEROMERO_GPU_MEM_UTIL 0.52 -> 0.51. 0.52 no longer boots because
orcarouter gen (the GPU0 neighbor) is marginally bigger than the old heresy seat
(free 49.02 < needed 49.38 GiB); 0.51 fits at KV 2.00x @ 262K.

For real RP-with-CoT: gen-reasoning (orcarouter, uncensored, thinks) or a dedicated
Qwen-based RP-thinking seat, which needs a GPU slot.
2026-08-21 08:20:57 -07:00