14bd95d76df24c8b796980bf5b282389878ef9c1
1291
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
14bd95d76d |
chore: extract the Booth to its own repo (vh/booth)
The Booth is now one of the most-used fleet tools -- 17 agent handles post to it daily -- and it is taking an information-architecture rework plus a cross-agent SVOS design retrofit from design-dev. That work wants its own ROADMAP, contracts and blast radius, not the fleet-infrastructure repo's. All 29 commits moved with it via `git subtree split`; the history carries real lessons (two shipped-dead controls, the verbatim-injection traps) that a squashed import would have thrown away. Live service repointed and verified: the user unit and the ~/.local/bin/booth symlink now resolve into ~/development/booth, healthz answers, all 24 booths intact. services/booth/ keeps a pointer README, same shape as the chatterbox-fast and tts-stack extractions. |
||
|
|
ddc7926cb9 |
docs: snapshot for context reset -- in-flight rewritten, Booth is next
The in-flight section had gone self-contradictory: its header said lv-mccarthy SHIPPED while the body still carried the pre-ship draft (It is NOT shipped for one reason...) and an unpushed-commit count that was no longer true. That section is decay-prone by design and rewrites freely; this is the rewrite. Now leads with the Booth (services/booth) as the next work, carrying the context a fresh session needs before its first question -- what the board is, and the warning that its last two sessions each shipped a control that did nothing, both found by the operator rather than by me, because I read templates instead of rendering them. lv-mccarthy is compressed to the ship facts plus its two carried-forward items: the length defect (open follow-up is a retrain folded into Faulkner's pair build per the operator's steer, not a checkpoint change) and the Skaldsong think-tag check. Every number lives in the detail file. Push state is now recorded as VERIFIED rather than asserted -- rev-list origin/main..HEAD = 0 -- and the wall-clock stamps are corrected to ~21:05 PT after the handoff generator's UTC stamp showed the session had run three hours past what I had written. NO ARCHIVAL this run despite the index sitting at 514 lines against a ~300 soft cap. Only 5 of 181 dated entries predate the 14-day guard, and all five carry an open deferred-work pointer (parked ospool/naspool-evac; run 3c staged and deliberately not launched; fused-MoE at park 47; nconnect=8 tracked at an althing thread; the AI-tab regrouping belayed, untracked by operator choice). The guard holds every one, which is the intended outcome -- an over-cap file that keeps live decisions beats a scannable one that lost a decision. |
||
|
|
3847d8b9fb |
docs(lv-mccarthy): record the ship, the process failure behind it, and a seat-wide think-tag defect
SHIPPED 2026-09-21 18:01 PT. lv-mccarthy = checkpoint-300, fourth voice on voices-seat (fv-ml1 GPU0 :8027). Seat healthy, five models served, GPU0 96,012 MiB against 96,090 with three adapters -- a LoRA rides inside the existing seat and costs nothing. Verified by read-back rather than by the deploy's exit code. Live smoke test: lv-mccarthy 96 words / 0 quote marks / "wasnt" with no apostrophe; lv-hemingway 135 words, no regression; voices-base 221 words, 12 quote marks and a visible reasoning preamble -- the adapter is doing real work. THE PROCESS FAILURE IS RECORDED BECAUSE IT IS THE LESSON. I held the ship three times and only the first hold was right. Hold 1 was correct: the gate as frozen failed both candidates. Hold 2 was wrong -- having proven my own axis C arithmetically unsatisfiable, I invented a STRICTER bar of my own and treated it as binding over an explicit authorisation. Hold 3 moved the goalposts: when I conceded the bar was mine, I reached for a second reason rather than executing. Finding successive reasons not to act on a delegated authorisation is its own failure mode, and it is harder to see than over-eagerness because every individual hold looks like caution. The tell was structural: each time one reason was refuted I produced another for the same conclusion. A concern that survives the refutation of its own grounds was never the real grounds. The cost shipped unglossed, in the compose, the adapter README and here: in-band 0.65 against base's 0.89, 35% of generations missing the 90-140 band against base's 11%. No checkpoint fixes it; the open follow-up is a retrain targeting length. AND A SEAT-WIDE DEFECT THE SMOKE TEST FOUND, LIVE SINCE 2026-09-16: every voice prefixes an empty think block unless the caller sends chat_template_kwargs enable_thinking false. It is the Qwen3 chat template, not an adapter property, so all four voices do it. No gate number is affected -- the harness sets the flag -- but a caller that omits it gets 17 junk characters at the head of every passage, and any word-count run over that string counts tags as prose. Skaldsong should be checked. |
||
|
|
6692701571 |
docs(voices-seat): every voice prefixes an empty think block unless the caller disables it
Found while smoke-testing the lv-mccarthy ship. Measured live: default -> '<think>\n\n</think>\n\nThere were no horses in the road...' enable_thinking=false -> 'The sun was hot on the dry riverbed and the stones were red...' This is the Qwen3-4B-Instruct CHAT TEMPLATE, not an adapter property, so it applies to lv-yarros, lv-bronte and lv-hemingway equally and has done since this seat went up on 2026-09-16. No gate number is affected: gen_beats_chat_yarros.py sets enable_thinking when the template supports it, so every arm in every r49 gate was generated without the tags. But a caller that does not pass chat_template_kwargs gets 17 junk characters at the head of every passage -- and any word-count or in-band check run over that string is counting the tags as prose. Skaldsong should be checked. |
||
|
|
17db37e1cc |
feat(voices-seat): ship lv-mccarthy (checkpoint-300) as the fourth author voice
Shipped on the operator's standing authorisation, "ship it if the gate passes".
The gate design of record for this line -- the operator's own v2 rule, ratified
2026-09-15 -- passes on all three axes:
VOICE +0.177 delta_cb at 3.2x the pairwise floor, the BEST arm of five,
and +0.128 at 2.8x on the pre-registered punctuation-stripped
secondary read, so ~3/4 of the gain is words rather than marks.
Closed 60.8% of the reachable span on an axis deliberately made
harder than Bronte's or Hemingway's.
NOT COPIED 0.12 hit-rate against HELD-OUT McCARTHY'S OWN 0.12 -- 1.0x the
author -- with a longest verbatim run of 10 words against the 12
genuinely unseen McCarthy shares with the train split by
coincidence. All 31 matches READ: stock grammar, name-shaped hits
are the renamed inventions, nothing protectable. This matters:
McCarthy is in copyright with a living estate.
NO DAMAGE ran-on +0.12 against the 0.200 floor, 40% headroom, and the
measured minimum across the five-arm ladder.
ckpt300 over ckpt900, the eval-loss minimum: the two-epoch recipe is now 0 for 3,
and this time the curve was CONFIDENT and wrong -- ckpt900 is the minimum and
ckpt300 sits +15.8x the median neighbour jitter above it, yet ckpt300 wins every
axis that resolves (3.2x vs 1.2x voice margin, 2.7x tighter seed spread,
memorisation 1.0x the author vs 1.8x, ran-on 0.13 vs 0.28). On this schedule read
the axes, not the curve.
THE COST IS REAL AND IS WRITTEN INTO THE COMPOSE, THE ADAPTER README AND THE
MEMORY RECORD RATHER THAN GLOSSED. In-band is 0.65 against base's 0.89 and on-beat
0.42 against 0.71 -- 35% of generations miss the requested 90-140 word band against
base's 11%. Axis C is ran-on only and is structurally blind to this; that blindness
was identified and written down before these numbers existed. No checkpoint choice
fixes it: every adapted arm is 33-48% out-of-band and ran-on is non-monotonic in
epoch. If a caller depends on a word budget, expect one generation in three to miss
it, and the fix is a retrain targeting length rather than a different checkpoint.
Rollback is one line and 0.003 s: drop the --lora-modules entry, or POST
/v1/unload_lora_adapter. The other three voices are untouched.
|
||
|
|
968ec71cce |
chore(r49): stage lv-mccarthy ckpt300 on fv-ml1, and guard the ship script against a local ~
The adapter is STAGED, NOT SHIPPED: adapter_config.json and adapter_model.safetensors are in /tank/aimodels/voice-adapters/lv-mccarthy-4b-v1 on fv-ml1, verified byte-identical to gx10's checkpoint-300 at the source, after the local hop and at the destination. The seat CANNOT load it -- nothing in --lora-modules references it -- and /v1/models was checked after staging and still serves exactly the three registered voices plus base. The adapter carries a README recording the full gate result AND its cost, so it cannot be read as clean by anyone who finds the directory without the gate record. A copy is committed at scripts/mccarthy-corpus/gate-results/ADAPTER-README.md for review. It leads with "STAGED, NOT SHIPPED" and says that if lv-mccarthy is absent from the seat's model list, the decision has not been made. The compose edit that would actually register it is prepared in the working tree and deliberately LEFT UNCOMMITTED. stacks/ is canonical intent that deploy-stack.sh reads; committing it would assert an intent that is precisely the open question. Ship-script fix: <run-dir> must be an absolute REMOTE path. A leading ~ is expanded by the local shell, so ~/r49-runs/... became /home/lkraven/... and gx10 answered "Permission denied" -- a confusing error for a path problem. It now refuses a non-absolute path outright, with the reason. Verified: the guard fires on a ~ path and the real staging succeeded on the absolute one. |
||
|
|
e8eb1594d9 |
docs(lv-mccarthy): five-arm ladder -- ckpt300 wins every axis, held at the gate by 0.02
1,200 generations across five arms. ckpt300 (epoch 0.652) is the best arm in the
run on every axis that resolves:
VOICE +0.177 at 3.2x its pairwise floor (primary), +0.128 at 2.8x with
every punctuation mark stripped. Best point estimate AND best
margin of any arm, spread 0.055/0.038 with no outlier seed.
MEMORISATION 0.12 against real unseen McCarthy's own 0.12 -- identical -- with
a longest match of 10 words against the author's coincidental 12.
All 31 matches read: stock grammar, names are the renamed
inventions, nothing protectable.
DAMAGE ran-on +0.12. Clears the operator's ratified v2 floor of 0.200 by
40%. FAILS AMENDMENT 3's self-imposed 0.100 bar by 0.02.
NOT SHIPPED, and the reason is the bar rather than the adapter. AMENDMENT 3 fixed
ran-on <= 0.100 before either new arm existed, precisely so a marginal number could
not be talked into a ship, and shipping at 0.12 would make that pre-registration
theatre. But the bar's stated rationale was written against ckpt450's pass by 0.01
-- 5% of the threshold -- and ckpt300 clears by 40%. The number excludes a candidate
the reasoning does not. That is an operator call.
ckpt325/350/375 are on disk and one may sit under 0.100. They were deliberately NOT
gated: searching the checkpoint space until something clears is candidate-shopping,
the same family as threshold-shopping approached from the other side.
THREE CLAIMS FROM EARLIER THIS SESSION ARE REFUTED and are corrected in the record:
1. "The damage is flat across epochs and only rotates direction" -- FALSE. ran-on
is non-monotonic (0.38 -> 0.13 -> 0.20 -> 0.28 across epochs 0.49/0.65/0.98/
1.96) with a real minimum near 0.65, and ckpt225 is 48% out-of-band against
ckpt300's 35%.
2. "ckpt300 runs far too short, ckpt225 will clear ran-on by being short" -- FALSE
on both. ckpt225 runs LONG (median 127, 38% over-band) and is the worst arm in
the run. I generalised from SIX generations of one arm, which is the exact n=1
violation the measurement-discipline rule names, committed in the same breath
as a note about being careful.
3. The original "gate an earlier checkpoint, the overshoot may not have arrived
yet" recommendation was RIGHT. Retracting it an hour later on a three-arm read
was the error, not the recommendation.
What is true and unresolved by any checkpoint choice: 35% of ckpt300's generations
miss the 90-140 band against base's 11%, and in-band is 0.65 against 0.89. An
adapter that buys a voice and costs a third of the length compliance is a trade, not
a defect -- but it is the operator's trade to accept.
Raw artifacts for all five arms at scripts/mccarthy-corpus/gate-results/.
|
||
|
|
3de4d2dfaf |
docs(lv-mccarthy): note the undershoot hole in AMENDMENT 3's bar without editing the bar
Six generations into ckpt300 its outputs are far too SHORT -- 6, 25, 46, 59 words against a 90-140 ask. At epoch 0.65 the adapter may not have learned the length target yet. That exposes a hole in the bar I set an hour ago: ran-on is defined as words > 140, so a chronically undershooting arm satisfies "ran-on <= 0.100" trivially while being useless. Same class as the unsatisfiable criterion AMENDMENT 3 exists to correct, inverted -- too easy rather than impossible. The bar is NOT being edited again. Three rule-motions in one session is already too many and a bar patched once per observation is not a bar. Instead the judgment is stated in the open: a candidate that clears the ran-on term by being short has cleared it vacuously and will not be shipped, and the full in-band / on-beat / median / p90 table is reported for every arm so a reader can see which kind of pass they are looking at. Gate design for this corpus is evidently not settled. That is an operator call, and the complete five-arm ladder is being measured so it can be made once rather than iteratively. |
||
|
|
43a8d7409e |
fix(lv-mccarthy): the axis C I froze is UNSATISFIABLE, so it never adjudicated anything
Arithmetic, not a re-reading of results. Criterion 1 needs the candidate's in-band rate to beat base's 0.89 by more than the 0.200 floor -- so it needs in_band > 1.09, and a rate cannot exceed 1. A PERFECT adapter, every one of 240 generations inside the 90-140 band, scores +0.11 and fails by 0.09. So "both candidates failed axis C" carries zero information about either candidate. That verdict was determined before a single generation existed. It is my error. The cause is specific: section 6 transcribed score_beats.py's v1 criteria, which the operator AMENDED on 2026-09-15 -- three weeks before this corpus was built -- for exactly this reason. Its own docstring: "NOT carried into v2: in-band (unresolvable -- base maxes it) and on-beat." The governing rule reverts to the operator's v2: axis C is ran-on only. That is the rule they ratified and the rule lv-hemingway shipped under. Reverting a transcription error to its source is not threshold-shopping; the source is older than the data. It does not rescue ckpt450 either. Under the corrected axis C its ran-on is +0.19 against a 0.200 floor -- a pass by 0.01, inside the noise the floor exists to describe, and not a pass worth shipping on an author in copyright with a living estate. AMENDMENT 3 therefore gates two more arms on the SAME frozen fixture, seeds and rule -- ckpt300 (epoch 0.65) and ckpt225 (epoch 0.49), both on disk because --save-total-limit 60 kept all 56 checkpoints -- since the damage grows monotonically with epoch and the voice may arrive before it does. And it sets the ship bar BEFORE those arms exist: voice clearing the pairwise floor on both reads, memorisation at or near the held-out author's 0.12 with every match read, and ran-on delta AT MOST 0.100 -- half the measured floor, not merely inside it, because a rate that clears a noise threshold by 5% of that threshold has not been shown to clear it. If nothing clears all three, lv-mccarthy is parked pending a retrain, and that is the report rather than a softer bar. |
||
|
|
4c3f3896f1 |
docs(lv-mccarthy): record the gate result -- voice passes, memorisation is the cleanest in the line, NOT shipped
720 generations, 3 arms x 60 held-out beats x 4 seeds, against the design frozen in GATE-PREREG.md before any arm existed. AXIS A VOICE -- PASS, both candidates, both reads. Span 0.661 -> 0.370 = 0.291 achievable; ckpt900 closed 59.1% (+0.172, but only 1.2x its floor), ckpt450 52.2% (+0.152 at 2.9x). The normalised secondary read HOLDS at +0.124 / +0.114, so about three quarters of the gain survives stripping every punctuation mark -- the voice is not the cheap win the register made available. AXIS B NOT COPIED -- ckpt450 is the cleanest result in the line. 0.12 hit-rate against the author's own held-out 0.12, and its longest match (11 words) is SHORTER than the author's coincidental longest (12). All 96 matched runs were read: stock grammar in the commonest words, the name-shaped hits are the RENAMED inventions, nothing protectable. The amendment is why this reads as clean -- the defective base control would have shown 0.12 vs 0.00 as a 12x red flag. Separately measured: the "his register makes collisions inevitable" story that was FALSE for Hemingway (0.01) is TRUE for McCarthy (0.12). Neither transfers. AXIS C NO DAMAGE -- FAIL, both, and it survives reading. 20% (ckpt450) / 28% (ckpt900) of generations overshoot the 90-140 band against base's 1%; p90 171/190 words, max 297/279. The worst case is degenerate looping, not a long McCarthy sentence. Base is GOOD on this axis here (0.89 in-band vs Hemingway's 0.05), so the adapter measurably makes instruction-following worse. NOT SHIPPED. Section 7 rule 3 makes axis C disqualifying outright. Recorded honestly: my own prereg's axis C transcribed score_beats.py's v1 criteria, including "in-band up on base", which the operator RETIRED on 2026-09-15 for exactly the reason it fails here -- base maxes it, so it is unsatisfiable on this corpus regardless of adapter quality. Under the operator's v2 (ran-on only) ckpt450 passes by 0.01 against a 0.200 floor. That reading was found AFTER the numbers and was NOT used; lv-bronte's floor defect is the in-line precedent for finding one and declining to exploit it. The prereg gets fixed prospectively for the next author, not re-read for this one. And the finding worth more than the adapter: the two-epoch recipe is now 0 for 3, and this time the loss curve was CONFIDENTLY wrong. On Bronte and Hemingway the epoch-1/epoch-2 checkpoints were tied, so preferring the earlier one cost nothing. Here the curve resolved epoch 2 as better at 4.9x the median neighbour jitter -- and epoch 2 lost every axis that resolves: 4.0x wider seed spread, 1.8x the author's memorisation rate against 1.0x, more ran-on, worse on-beat. Its only win is a 0.019 voice point estimate, inside the floor, and its spread is one outlier seed -- the third occurrence of that shape in the later checkpoint after lv-bronte's ckpt925 and lv-hemingway's ckpt1750. Raw artifacts committed at scripts/mccarthy-corpus/gate-results/ so the claims can be re-read without gx10. |
||
|
|
0d80e493a8 |
fix(r49): the quote-mark counter was counting apostrophes, and I saw it fire before I saw the bug
voice_distance.py's quote class shipped this morning as "'‘’“”«»‹›‚„` -- with the
apostrophe characters in it. On a corpus whose defining tic is dont/aint/wont and
whose possessives are everywhere, that made it an apostrophe counter wearing a
quote-mark label.
as implemented TRUE quotes all apostrophes
held-out McCarthy ref 121.1 0.0 121.1
base-unadapted 224.7 19.9 204.8
held-out Hemingway ref 1112.6 694.7 351.7
The corrected column is the one the pre-registration names: 0.0 for McCarthy,
which is exactly what build_corpus_mccarthy.py ASSERTS, and 694.7 for Hemingway,
the documented ~838 scale the 100-per-10k trigger line was anchored to. The
as-implemented column matched neither.
ORDER OF EVENTS, because it is the material fact: the base arm finished first, so
the trigger became evaluable while the adapted arms were still generating. I
evaluated it, saw it FIRE at 224.7, and only then -- reading the reference row of
my own table against a corpus I knew asserts 0.0 -- found the bug. No delta_cb,
memorisation rate or damage number had been read at any point.
Fixing a detector to measure the quantity the frozen rule names is not moving the
rule, but the fix un-fires the trigger and no reader should have to take my word
about my motives. So GATE-PREREG.md AMENDMENT 2 makes the trigger MOOT instead of
adjudicating it: the normalised secondary read is load-bearing UNCONDITIONALLY for
this gate, whichever reading you accept, both columns reported. The fix therefore
has no effect on the verdict.
There is a better reason than the bug anyway: base's true quote density is 19.9
against the reference's 0.0, so it did not fully comply. A small residual cheap win
IS available to the adapter, and the normalised read is what prices it. A threshold
is a blunt instrument for a residual that size.
Apostrophes now get their own column and are never folded into quotes again.
Default path stays byte-identical to the shipped lv-hemingway artifact.
The durable lesson is the one this line keeps relearning in new places: I controlled
strip_punct (2500 -> 0) and the byte-identity of the default path, but never asked
the quote counter for a value whose answer I already knew. The corpus asserts 0.0.
That check cost one line and was available before the gate ever launched.
|
||
|
|
a601267fa5 |
feat(r49): script the voice-adapter ship with sha verification across both hops
Shipping a gated adapter was ad-hoc ssh + rsync three times running. This makes it one reproducible command for the rest of the line (Faulkner, Morrison, Chandler are next). The sha verification is the point, not decoration. "Copied the adapter" is a claim with no honest form but a read-back, and a truncated or half-written adapter loads without complaint and serves a subtly different voice. The digest is taken at the source on gx10, after the local hop, and at the destination on fv-ml1; any mismatch aborts before the seat is ever told the file exists. It ships only adapter_config.json and adapter_model.safetensors. A checkpoint dir also holds optimizer state and RNG, which are large, useless to the seat, and would make the destination digest disagree with any future re-ship. It deliberately does NOT edit the compose or restart the seat. Registering an adapter is a reviewed edit to stacks/voices-seat/compose.yaml, because that file is where the gate verdict and its caveats get written down for whoever reads it next -- that is a feature of the current process, not friction to automate away. Verified against the already-shipped lv-hemingway: the live adapter on fv-ml1 is byte-identical to gx10's checkpoint-850 on both files, which confirms the file list and the convention this script encodes. |
||
|
|
82a8e0c9b1 |
feat(r49): commit the instrument for READING memorisation matches
memorization_check.py answers HOW OFTEN an arm collides with the training text. It does not answer WHAT is being reproduced, and those have different consequences: a low rate hiding one 30-word run of distinctive imagery is worse than a high rate of stock dialogue in the commonest words in English. The lv-hemingway gate shipped at 7x the author's own collision rate precisely because that reading was done -- every matched run was stock dialogue, max 9 words, no proper noun. But it was done BY HAND and left no instrument, so the next gate had to repeat it by hand or skip it. This is it. Prints every maximal matched run sorted by length, with arm/id/seed, and flags any token capitalised mid-run as a possible proper noun. The flag deliberately over-reports (sentence-initial I, He, What all trip it) because it is a reading aid and over-reporting is the safe direction. --train-only matches memorization_check.py so a collision with held-out text is not reported as memorisation of training text. A corpus slice is matched against the corpus every run, and the script REFUSES if that positive control fails -- a matcher that only ever sees candidate text cannot tell "no matches" from "blind". Validated against the lv-hemingway record's documented reading, which it reproduces to the word: longest run 9 words, "swift tristan" flagged as the one name-shaped hit (it is the RENAMED invented name, not Hemingway's), and the record's quoted examples -- "came over and sat down at the table", "i don t think so the girl said" -- both present. Required by GATE-PREREG.md AMENDMENT 1, which makes reading the matches part of axis B rather than a follow-up, because McCarthy is in copyright with a living estate and a match carrying distinctive imagery or a proper noun is disqualifying in a way a rate number alone is not. |
||
|
|
31e40d8ffa |
docs(lv-mccarthy): extend the runbook through D5 train and D6 gate
The runbook stopped at D4 pairs, so the two stages that actually produce the shippable artifact were unrecorded -- the same gap that forced the 2026-09-17 D1->D3 chain to be recovered by rebuilding candidates and matching sha256. D5 records the 1,380-step run, the loss curve read (median neighbour jitter 0.00393; ckpt900 the minimum, ckpt450 at +4.9x, adapter/ at +18.4x), the fact that the epoch-3 collapse STEPS rather than drifts, and that adapter/ is the epoch-3 weights so whatever ships is a checkpoint. It also records the four provenance fields that look wrong and are not -- all four reproduce on the yarros and hemingway runs -- and how to bind the run to its pairs by RECORD COUNT rather than by the relative path it stored. D6 records the gate invocation, the four deviations from the lv-hemingway gate and what forced each, and the two instrument questions that had to be settled before it could run: the punctuation confound on the voice axis (primary / secondary / pre-registered trigger) and the defective base-unadapted control on the memorisation axis. Plus one thing that will otherwise be misread: gen_beats_chat_yarros.py prints RAN-ON when it finds no paragraph break, a heuristic written for the Yarros register. McCarthy's register asks for continuous scene prose, so an unbroken block is the TARGET here and the tag fires on most generations without meaning damage. The axis-C metric is a different definition and is unaffected. |
||
|
|
b4ba731b26 |
fix(r49): commit the held-out-author memorisation control, and amend the mccarthy prereg to use it
The lv-hemingway gate established that memorization_check.py's negative control is
defective: base-unadapted writes SUMMARY while the adapted arms write PASTICHE, and
text that does not imitate a register cannot collide with that register's n-grams.
Base's 0.00 partly measures "different register", not "did not memorise". A negative
control that differs from the candidate in a way correlated with the metric is not a
control.
The correct innocent sample is the author himself -- held-out text no arm trained on,
in the same register by construction. That control was computed BY HAND during the
lv-hemingway gate and never committed, so the finding it produced was not
reproducible. This commits it as --heldout-reference, with --train-only to build the
n-gram set from split=="train" records only (the default includes val, so a collision
with held-out text was being counted as memorisation of training text -- an overcount
Bronte's and Hemingway's numbers both carry).
--heldout-reference without --train-only REFUSES, because the held-out text would be
scored against a gram set containing itself.
Instrument controls:
- default path reproduces the shipped lv-hemingway memorization.txt BYTE FOR BYTE
- the new held-out row reproduces the lv-hemingway record's hand-computed numbers
to the digit: 370 samples, hit-rate 0.01, mean-longest 0.1, max 10, at the same
101-word median chunk length -- an independently written instrument reproducing a
documented finding is what makes its zeroes mean absent rather than blind
GATE-PREREG.md AMENDMENT 1 adopts it for lv-mccarthy, appended rather than folded in
so the order of events stays legible. Written while the base arm was still generating
and before any McCarthy number had been read. It moves the reference a candidate is
compared against from a known-broken one to a known-correct one, in the direction
that makes the axis HARDER to pass, on an argument published four days before this
gate existed. Section 7's tiebreak is amended to match, and the amendment records
that an elevated rate is not by itself a no-ship -- rate and exposure are different
questions, every matched run gets READ, and McCarthy being in copyright with a living
estate makes a distinctive match disqualifying in a way a rate number alone is not.
|
||
|
|
9c8a4e959c |
feat(lv-mccarthy): pre-register the v2 gate before any arm is generated
Freezes the lv-mccarthy gate design while no McCarthy generation exists, per the
lv-hemingway precedent: a threshold chosen after seeing the numbers is not a
threshold. Three arms (base-unadapted, ckpt900, ckpt450), 60 beats, 4 seeds.
Settles the voice-axis question D1 deferred. McCarthy's corpus measures 0.0 quote
marks per 10k against Hemingway's 838, so "emit no quotation marks" is the cheapest
way to move a char-bigram Delta without learning a sentence. Three parts:
PRIMARY unchanged -- the mccarthy register names the punctuation and
--system-from drives the base control with the same prompt, so the
cheap win is handed to both sides.
SECONDARY voice_distance.py --secondary-normalised re-runs the whole analysis
with punctuation stripped from the reference and every arm. A
conservative lower bound; reported, never the verdict.
TRIGGER voice_distance.py --punct-report evaluates a pre-registered line --
base-unadapted quote density above 100 per 10k means the control did
not take the win it was handed, and the normalised read is promoted
to load-bearing. 100 is the order-of-magnitude line between this
corpus's 0.0 and Hemingway's 838, chosen now rather than after the
table prints.
ckpt450 is NOT tied with the minimum (+4.9x the 0.00393 median neighbour jitter)
and the pre-registration says so. It is generated to test a stated prior and to
price the memorisation headroom an earlier checkpoint buys on an in-copyright
author, with a decision rule that says exactly what result would let it displace
ckpt900.
Instrument controls, run before this landed:
- the voice_distance.py refactor reproduces the shipped lv-hemingway
voice_distance.txt BYTE FOR BYTE on the default path
- strip_punct drops a quote-bearing sample from 2500.0 to 0.0 marks per 10k
- the secondary read still resolves a gap on the Hemingway arms (+0.410 at
7.3x floor), so a null on McCarthy would be a finding, not a blind detector
Pre-flight re-run at gate time rather than quoted from 2026-09-17:
- leak gate: 0 of 75 renameable, 0 of 37 sub-threshold, 0 separator-split,
four controls green
- beat-contamination audit: 0 of 3942 beats AND 0 of 3942 responses, against
Hemingway's 70 of 7094 -- build_sft_pairs.py --source-entities earned its
mandatory flag
Also records three provenance defects found on first read of the run and their
disposition: the hardcoded "r49-babyyarros-pairs-pilot" run label (cosmetic, same
literal on all three runs), the empty harness_commit (all three runs), and a
pairs_sha256_16 that is not a sha256sum of the file (consistent across runs, so a
cache key rather than a fault). The run is bound to McCarthy's pairs by record
count, not by the label.
|
||
|
|
e52def115c |
memory: snapshot — the ops log, and a day spent on instruments that report without looking
Archived 15 entries (Recent decisions 14, Tried and abandoned 1) oldest-first to archival-memory.md; 5 held back on the open-deferred-work guard and 164 on the 14-day guard, so the index stays over the soft cap at 477 lines. An over-cap file that keeps live decisions beats a scannable one that lost a belayed item. Four new detail files cover the day: the ops log and its four self-inflicted failure modes, the Booth's two dead controls and the four-iteration layout probe, the Gitea org grant plus the dead claude-bot token that had been misreporting permissions, and the disk triage that rescued a LoRA adapter from a directory this box sweeps at three days. lv-mccarthy's run outcome remains unverified after two days and is the first line of the in-flight section and step 1 of the handoff. |
||
|
|
2e08edcaab |
fix(draupnir-slicer): warn that every invocation writes result.json to CWD
brokkr-smithy-dev exercised the slicer on real geometry and found OrcaSlicer writes result.json into the working directory on --info. I verified it here and it is worse: --help does it too, which is as read-only as an invocation gets. An empty directory gained a result.json from --help alone. Encoded in the wrapper and the Dockerfile rather than left to be rediscovered, and the wrapper is now committed beside the image that backs it instead of living only in a home directory on one host. ⚠ The reason they found it and I did not is worth keeping in the header: I verified --help and called the slicer done. Usage text proves the binary starts, not that it ingests our geometry or that it behaves while doing so. They ran both controls through to STL and got manifold=yes, 520 facets, return_code 0 on each -- which is the verification I should have run before reporting the provisioning complete. |
||
|
|
e574b91ff3 |
feat(irv-ml1): containerise OrcaSlicer for Draupnir's sliceability check
Not tidiness — necessity, established by ldd rather than assumed. Every OrcaSlicer release ships only an Ubuntu 24.04 AppImage, which needs GLIBC_2.38 and GLIBCXX_3.4.32. irv-ml1 is Debian 12 on glibc 2.36. That gap is not installable without moving the host to Debian 13, which is not a thing to do to a box running twelve GPU and audio services in order to slice a plate. Reaching back to an Orca built for Ubuntu 22.04 would run, and would mean pinning permanently to a stale build -- the exact trap brokkr-smithy-dev rejected when they chose Orca over an old PrusaSlicer tag. The container keeps the slicer current AND leaves the host alone, which is the same argument that made FreeCAD an AppImage, taken one step further because the host could not satisfy this one at all. The AppImage is extracted at BUILD time; --appimage-extract-and-run re-extracts to /tmp on every invocation, which is wasted seconds and wasted disk per slice. ~/bin/orca-slice wraps it so the dispatcher calls a binary and the artifact root is mounted at the same path inside and out. ⚠ Recorded honestly: I installed libwebkit2gtk-4.1-0 and 8 dependencies on the host chasing errors serially before enumerating with ldd, and only then found the glibc wall behind them. Those nine packages are unnecessary. Left in place rather than autoremoved on a box running production services; flagged for the operator. |
||
|
|
34c4179d55 |
feat(irv-ml1): provision the Draupnir geometry engine, acceptance-tested
Operator-authorized. build123d 0.12.0 + OCP, numpy 2.4.6, trimesh 5.1.0 in a 3.11 venv, FreeCAD 1.0.0 headless as an AppImage, artifact root on smithy. The acceptance test is the point and it PASSES: brokkr-smithy-dev shipped a contrastive control pair whose every property is construction-known, so the install is checked against arithmetic rather than against a render. Both parts build, both bounding boxes are exactly 40x40x5, and both volumes match to float tolerance. These scripts had never been executed by anyone -- build123d is deliberately absent from the authoring box -- so this was their first run. FreeCAD is an AppImage BY CHOICE: the distro package drags a GUI/Qt dependency tree onto a host running twelve GPU and audio services, where an AppImage is self-contained, pinned and removable with one rm. Slicer DEFERRED rather than guessed. PrusaSlicer's latest release ships no Linux AppImage at all, and a guessed release filename had already produced a 4 KB error page in this same session. It is the one item they said could lag. Two things worth keeping in the header: irv-ml1's ssh config pins the gitea host to gitea_brokkr_smithy with IdentitiesOnly, whose comment is irv-ml1-r14-deploy and NOT the arbo key that sorts first in ~/.ssh/*.pub -- I registered the wrong one first, and the failure names the right one. And `ssh irv-ml1` is lkraven with no NOPASSWD sudo while `ssh infra-ops@irv-ml1` has it, so only the artifact-root step needs the other identity. The deploy key was registered with claude-bot's own token rather than the operator's site-admin token -- the org grant from earlier today doing real work on its first use. |
||
|
|
07c9cb293b |
fix(booth): release was painting over the delete ×, which was unclickable
Operator: "release button covers delete button". Measured before touching
anything: release 58x24 at (323,266), × 30x30 at (349,268) — 30x22 px of
overlap on a 30px button, and `elementFromPoint` at the ×'s centre returned the
release form. The × I added yesterday was 100% unclickable from the moment it
shipped.
Cause: both were `position:absolute` on the same corner, each with its own
guessed offset, and `release` is the later sibling so it won. Replaced with one
flex row positioned once — release left, × right at the card corner where the
ephemeral lane's × already lives, so muscle memory transfers and neither can
drift back on top of the other when a label changes width.
Verified by measurement, not inspection: overlap 0 px, and clicks at each
control's centre now land on that control. The ephemeral lane's × and ★ were
re-checked and are unaffected.
ADDS scripts/layout-probe.py, because markup inspection STRUCTURALLY cannot see
this and I have now shipped two dead controls in two days by reading templates
instead of rendering them. It asks a real browser what a click at each
control's centre would hit.
It took four iterations to become trustworthy and the failures are the point:
1. `top.contains(el)` counted an ANCESTOR overlay as a hit -- the exact case
it exists to catch. Version one reported OK for a real overlay.
2. elementFromPoint is viewport-relative, so everything below the fold read
as occluded and buried the real findings.
3. getBoundingClientRect() on a WRAPPED INLINE element is the union of its
line boxes, whose centre lands in the gutter between them -- three zip
links reported occluded by their own parent. Real geometry, wrong question.
Only the fourth version fires on a genuine overlay while staying silent on the
clean page. Both controls were run; a probe never seen to fail proves nothing.
173 tests pass.
|
||
|
|
751eecb771 |
fix(booth): the reveal button was inert; add kept-lane wipe and in-booth keep
Three operator reports, one of them a real defect I had claimed was working. THE REVEAL BUTTON DID NOTHING, for a day. Its handler sat after the content block's closing tag, and a child template's out-of-block content is silently DISCARDED by Jinja. The button rendered. The handler never reached the browser. Two commits and a README paragraph said click-to-reveal worked, and the suite passed the entire time because nothing asserted against the SERVED page -- the template really did contain the code. Two guards, both confirmed to FAIL when the defect is reintroduced rather than merely added and assumed protective: * test_reveal_handler_actually_reaches_the_served_page greps the response * test_no_orphaned_markup_after_the_content_block guards the structure While moving it, caught a second instance of the same class: the explanatory comment I wrote for the fix contained a literal Jinja endblock tag, which Jinja would have parsed as a real tag and used to close the block early. KEPT-LANE ×. Wiping a kept booth required release-then-find-it-in-the-other- lane. That protected nothing and cost a hunt -- the board you just released is loose in a feed that turns over, and you have to go find it to finish a job you had already decided on. Protection now lives in the confirmation, which names the booth and says KEPT, instead of in the number of lanes you must traverse. Release stays as the reversible option. IN-BOOTH KEEP. `☆ keep` / `★ kept — release` beside "Wipe now", so promoting does not mean navigating back to the index. The booth page did not previously know its own kept state; it does now. Both post a `next` field to stay put -- and `next` is a form field, so it is attacker-controlled: only same-site absolute paths are honoured, with `//host`, schemes and backslashes refused, tested. 173 tests pass. |
||
|
|
8227a81272 | memory: ravenpen.com registered — registrar, expiry, and the scope boundary I can't cross | ||
|
|
838ea60fc0 | homepage: surface the infra-hermes bus seat status page (nh3-dev:8766) | ||
|
|
7fe4102458 |
fix(backups): stop saying STALE over a fleet whose every backup is fresh
The check collapsed two different findings into one verdict. On 2026-09-20 it printed "RESULT: STALE" while reporting 37 FRESH layers and zero stale ones -- every backup body provably current, the three ❌ rows all yesterday's pre-fix runs aging out of the 36h window. infra-hermes caught it in triage: a reader, or a forwarder, could page someone over a state where nothing is stale. STALE is a claim about backup AGE. A job that ran and errored is a different claim with different urgency. They now have different words and different exit codes: 0 all backups fresh 1 STALE -- a body past the threshold, or an endpoint down 3 ERRORED-JOBS -- every body fresh, a vzdump job errored recently The alert wrapper mirrors the code and matches its own wording to the finding: 🟡 "Backup jobs errored — all bodies fresh" instead of 🔴 "Backup freshness ALERT", and it now exits with the check's code rather than flattening everything to 1, so `systemctl status` distinguishes the states too. This is the same defect class the rest of this script was built to fix, one level up: not an instrument that fails to look, but one that looks correctly and then reports the wrong word for what it saw. An alarm that cries outage over a healthy fleet earns being ignored exactly as fast as one that stays silent over a broken one. Verified all three states by forcing each: BACKUP_JOB_WINDOW_HOURS=1 -> exit 0, default -> exit 3, BACKUP_MAX_AGE_HOURS=1 -> exit 1. |
||
|
|
271cb11b70 |
fix(booth): put the blur toggle on every item kind, and make it look like a control
The operator asked "no UI option to blur/unblur?" and was right twice over. MISSING ENTIRELY ON TWO OF THREE BRANCHES. booth.html renders docs, media and everything-else through separate <figure> blocks. The toggle went into the media branch only, so inline docs -- the branch that puts readable text straight on the page, the one that needs blur most -- had no control at all, and `other` files only got a caption row if they happened to carry a caption. This is the SECOND time this feature shipped having patched some branches and not others; the blurred class itself had the same gap one commit ago. So the toggle is now a single Jinja macro called from all three sites, which makes "patched two of three" impossible rather than merely unlikely, and test_every_item_kind_gets_exactly_one_blur_toggle counts toggles against figures across mixed kinds so a fourth branch cannot quietly skip it. INVISIBLE WHERE IT DID RENDER. v1 was a bare `◌` at 0.78rem in --muted with no border, no label and no hover affordance. It now reads `◌ blur` / `◉ blurred` with a border, matching the other per-item controls. A control nobody can find is a control that is not there. Docs get it in the doc bar beside ⤢ ⬇ ✕, with stopPropagation so submitting it does not collapse the <details> it lives inside. Verified live on all three kinds: 3 figures, 3 toggles, and the POST round trip blurs and un-blurs. 167 tests pass. |
||
|
|
2e7fd7157e |
feat(booth): close the keep round trip, and add cosmetic per-item blur
Two operator requests.
KEEP, BOTH DIRECTIONS. The kept lane could already release a booth back to
ephemeral, but an ephemeral booth could only be promoted from a shell -- so the
round trip was closed only if you had ssh. The /keep route and the `booth keep`
verb both already existed; only the button was missing. Adds ★ to the ephemeral
card, mirroring × on the other shoulder.
BLUR. Per-item cosmetic censoring: `booth blur <name> <file>...`, a ◌/◉ toggle
in each caption row, and 👁 click-to-reveal. State is `.blurred` in the booth
dir, one booth-relative path per line -- the same filesystem-is-the-state idiom
as .pins and .forever. An empty set deletes the marker rather than leaving a
zero-byte file, so `ls -a` tells the truth.
⚠ BLUR IS NOT ACCESS CONTROL, and the code, the docs and a test all say so on
purpose. A blurred item is still served at its own URL, still in the zip, still
on disk. The Booth has no auth by design. test_blur_is_cosmetic_the_file_is_
still_served asserts the 200 deliberately: if someone later "hardens" this into
a 403 that test fails, and it should, because half-implemented access control is
more dangerous than none.
Reveal is per-viewer and never persisted; a reload re-hides. With JS off an item
stays blurred, which is the safe direction to fail in.
Two things the first pass got wrong, both caught by checking rather than
assuming:
* The cover thumb. index.html has IDENTICAL markup in the kept and ephemeral
lanes, so a single-occurrence replace patched only the kept one and the
ephemeral front page happily displayed the thing someone had hidden. The
test that caught it was itself wrong first -- it matched the bare string
"blurred-thumb", which is in base.html's stylesheet on every page and so
passed in both states. It now asserts the attribute.
* Inline docs render through their own <figure> branch and were left
unblurred -- the branch that puts readable text straight on the page, so it
needed blur more than images do. The suite passed; a live curl caught it.
165 tests pass (154 pre-existing, unchanged).
|
||
|
|
8305145ce1 |
docs(fv-ml1): record the 20 A circuit, its real ceiling, and what it forbids
Operator confirmed 2026-09-19 that fv-ml1 and the R420 running OPNsense are the only loads on a dedicated 20 A circuit. The governing number is 1920 W, not 2400: a GPU inference host running for hours is a continuous load, so NEC's 80% rule applies. Worst case lands at ~1625 W with the current caps -- about 85% of budget. Measured via the BMC rather than assumed: 390 W instantaneous, 461 W max over a 2423 s sample, with GPUs at idle, giving a ~313 W non-GPU baseline. Compare the GPU caps against the 300 W stock TGP, NOT the 325 W firmware ceiling. The operator corrected this: 275 W across four cards saves 100 W, not the 200 W you get by measuring against a number nobody would ever run at. Stock 300 W would put the circuit near 90%, which is not illegal but leaves nothing for a heavier R420, PSU efficiency, or a warm day. Keep the caps. The coupling matters more than the trip. OPNsense IS the Fountain Valley edge and shares the breaker with the thing most likely to trip it, so an overload takes the router with it and removes the remote path needed to diagnose or power-cycle anything. fv-ml1's four PSUs do not help -- PSU redundancy protects against a PSU dying, not against the circuit going away. Three things are explicitly NOT measured and the file says so: fv-ml1 under real 4-GPU load, whether the BMC reports AC input or DC output, and the R420's actual draw. Treat 1625 W as a floor. Also corrects the hardware section, which claimed 2x GPUs. nvidia-smi reports four. |
||
|
|
cebffae56d |
docs(ops-log): record how this instrument has failed, not just the fixes
Four attribution failures shipped on the day it was built, and every one of them RECORDED SOMETHING -- just not something findable or attributable. The individual fixes are in the commit log; the PATTERN was not written down anywhere, so a future session would meet each one as an isolated bug rather than as the fifth instance of a known shape. infra-hermes asked for this after the fourth one: 'worth an audit line item so the pattern is visible, not just the fixes.' He was right. The operative warning is at the bottom: absence of an entry is not evidence of absence of the event until you have checked this tool could have recorded it. |
||
|
|
f3b68e23a4 |
fix(ops-log): record the host's NAME, not the ssh target
elway passes its `host` argument straight through, and that argument is an ssh target. Five records of a real jobs.cfg change on esh-pve were written under host `infra-ops@esh-pve`. The consequence was not cosmetic. infra-hermes triaged the ESH backup job errors, queried `--host esh-pve`, got nothing back, and correctly reported the 05:29 exclusion of VM 102 as an unattributed change. The record had existed the whole time, five lines of it, under a name nobody would think to ask for. A log you cannot query under the obvious name is not a log. `normalize_host` strips any `user@` prefix on write AND on query, so records written before this fix are findable too, and `audit` -- which matches records by host name -- stops missing them as well. Verified in both directions: a record written with `infra-ops@esh-pve` and one written with `esh-pve` now both return for `--host esh-pve`, and the five historical elway entries surface under that query. |
||
|
|
2e823538f7 | memory: snapshot — the 3.7.0 rollout, and three tries to attribute a commit | ||
|
|
9141a41f6c |
fix(ops-log): attribution must run FIRST, not after another hook's exits
The commit hook was APPENDED to .git/hooks/post-commit so as not to disturb
graphify's block -- and that made attribution a subordinate clause of
graphify's control flow. graphify owns EIGHT `exit 0` paths (rebase / merge /
cherry-pick in progress, GRAPHIFY_SKIP_HOOK, no changed files,
graphify-out-only, no python found), and every one of them returns before
reaching an appended line.
infra-hermes reported commits going unlogged and proposed a lock race: the
ops-log fcntl path held by graphify's detached rebuild long enough for a
best-effort `|| true` invocation to drop. Plausible, and wrong. The cause is
ordering and it is deterministic.
|
||
|
|
eaaacc1c84 | Refresh graphify report | ||
|
|
4e778aeddc |
fix(ops-log): say 'unattributed' when there is no handle, not a unix login
The fallback recorded `lkraven@nh3-dev` when ALTHING_HANDLE was unset, which reads like an answer while being the absence of one. It now records `unattributed(lkraven@nh3-dev)` -- handles never contain @ or parentheses, so the fallback cannot be mistaken for an identity. Found by the commit hook's first independent test, which was not one I ran: infra-hermes committed |
||
|
|
0fe4da64c4 |
Guard seat test scripts behind __main__; clean _t_* cron output residue
Both harnesses executed top-to-bottom on import, so anything collecting them (pytest, a glob import) ran live-store side effects. main() guards plus finally-block cleanup of the _t_* cron output dirs the fabricated jobs leave behind. |
||
|
|
0dc8e9096e |
feat(ops-log): record who committed, by althing handle
Two agents share ONE checkout of this repo on nh3-dev, and every commit here
is attributed to Vuong Hoang by convention -- so a commit's author line says
nothing about which agent made it. The ops log closed that gap for HOST
changes; it did not cover commits to the shared tree.
Found the hard way today:
|
||
|
|
3e7d3a3e3d |
fix(ops-log): a sub-tool must not drop the claim wrapping its caller
deploy-stack.sh claims and releases around its own work. When the agent already held a longer claim for a multi-step operation, the deploy refreshed it, then released it on exit -- silently dropping the protection partway through the very operation it was guarding. Caught live: a 45-minute claim on nh3-docker/althing-post-office, taken to cover a build-push-deploy-verify rollout, was gone by the time the rollout finished. Nothing refused anything, and nothing said so. `ops-log claim` now exits 10 when the claim was already the caller's, and leaves the holder file untouched. deploy-stack.sh treats 10 as "not mine to release". Untouched matters as much as the exit code: a refresh would overwrite the reason and TTL the original claimant chose, so a deliberate 45-minute "3.7.0 rollout in progress" would degrade into "deploy-stack.sh <host> <stack>" and whoever got refused would read the wrong story. Verified in three states: no pre-existing claim -> deploy claims and releases; caller's own wider claim -> deploy refreshes nothing and leaves it standing with its original reason; another agent's claim -> still refused with exit 3. |
||
|
|
920f37c6c2 |
chore(althing): pin the post office to 3.7.0
Canonical pin bump for the 3.7.0 rollout forseti approved and released (althing 6db955f, tag v3.7.0). Image built from that tree and pushed as sha256:df0709b3f532557aebb47d6ece486ae32e851776f732862094608d55eefcd4e7; previous pin was 3.6.3@sha256:978f8553 and remains the rollback target. Content-verified rather than tag-verified: `postbox --version` inside the built image reports 3.7.0 before it was pushed, and again inside the running container after. A tag is a claim. Also adds playbooks/nh3-extdev-althing-upgrade.yaml -- a version-parameterised upgrade for the box a git pull cannot move. The existing nh3-extdev-althing-v3.yaml is the historical v2->v3 CUTOVER and is left alone rather than mutated per release; it still carries v2-retirement steps that document what happened. Every check in the new playbook asserts what the installed entry point reports, including one that the herald restarted within the last 5 minutes rather than surviving from before -- "active" does not mean "running the code you just installed". |
||
|
|
e43e2626f1 |
Add althing seat monitor-behavior test artifacts
Disposable harnesses from the wake-RFI thread (01M2WT3F): exit1-behavior-test.py covers the monitor exit-code/gate path including a sustained-failure/no-backoff assertion; hash_gate_repro.py covers the detection-time hash persistence crash-before-read boundary with consumption/unchanged/changed controls. Both fail nonzero on mismatch. Committing so seat artifact provenance rides git rather than file mtimes. |
||
|
|
5fee7868b3 |
fix(playbooks): don't let the preserve step depend on its own guard
/etc/restic is 0700 root, so before elway
|
||
|
|
519fca4669 | memory: snapshot — a dead alarm, two blind instruments, and an elway bug that reported success | ||
|
|
e50e57b5f2 |
chore(esh): exclude VM 102 from the nightly backup job
esh-vm-workstation is a Windows 11 Parsec/RDP sandbox -- no password, no state anyone needs recovered (operator ruling, 2026-09-19). Its nightly vzdump has failed since ~2026-09-06 with "timeout waiting on systemd", and the ESH job has reported `job errors` every night as a result, so a guest nobody needs backed up was manufacturing a real job failure daily. The job uses an explicit `vmid` INCLUDE list, so exclusion means removal from that list -- there is no `exclude` directive here, which is the same distinction that makes esh-scale CT 108 invisible in any exclude list. The reason now lives in the job's own `comment`, and check-backup-freshness.sh reports 102 under "NOT BACKED UP BY POLICY" rather than going quiet about it. This is a policy decision, not a fix: the systemd-scope failure on stopped guests is NOT diagnosed, merely no longer reached on this node. A stopped guest that DOES matter would hit the same wall. The playbook carries two hard-won warnings in its header. Read the job through the API, never `grep -A<n>` on jobs.cfg -- `vmid` sorts last in the block, so adding the comment line pushed it outside the window and every condition silently inverted. And use YAML block scalars for the JSON-extraction expressions; double-quoted ones did not survive YAML unescaping plus elway's bash -c quoting. Verified: vmid 100,101,103,104,105,106,107; the other seven guests still covered; job still enabled; a rerun skips the change rather than repeating it. |
||
|
|
754db4bc0b |
fix(elway): evaluate when:/creates:/changed_when: with the step's own sudo
Conditions ran unprivileged no matter what the step declared, and that fails in the dangerous direction. A root-requiring `when:` -- `pvesh get ...` exits 255 for a non-root user -- returns non-zero, elway reports the step `skipped`, and a playbook that never performed its change reports overall OK. "Skipped" is indistinguishable from working idempotency, so the run looks correct. Found the hard way on esh-pve: three consecutive runs of an exclusion playbook reported success while changing nothing. Only the verify phase caught it, by continuing to report the thing the steps claimed to have handled -- which is exactly why verify runs unconditionally. `creates:` had the same fault from the other side: a path under /root is unreadable to the login user, so `test -e` said absent and the step re-ran every time. It now correctly reports the file as already present. Sudo-less steps are unaffected: their conditions still evaluate as the login user, which is what they mean. Only a step that declares sudo: true gets privileged condition evaluation, so no existing playbook changes meaning unless it was already silently broken. |
||
|
|
ba26852ec6 |
feat(backups): catch a job that runs and errors, not just one that goes stale
Snapshot age is structurally blind to a backup job that executes every night
and fails every night. Nothing new is written, so the group simply ages, and
the fault only surfaces once it crosses the 48h threshold -- days after the
first failure, with the evidence sitting in a task log nobody reads.
Two live cases, both found today and both invisible for a week by this exact
mechanism:
* esh-nas-pve CT 107 (vm-jellyfin): a backup run died around 09-06 and left
a stale `backup` lock, so every nightly since failed instantly with "CT is
locked (backup)". Age named it on ~09-12. Task status would have named it
on 09-07.
* esh-pve VM 102 (esh-vm-workstation): failing nightly since ~09-06 with
"timeout waiting on systemd". Same six-day gap.
PVE already records every task result in /var/log/pve/tasks/index. This reads
it on all four non-tenant PVE nodes and reports any vzdump in the last 36h
whose status is not OK, as its own section that sets the exit code.
It found a third case on its first run: esh-nas-pve's job had been reporting
`job errors` nightly while every guest on that node read 0-1h fresh, so no
age-based check could ever have flagged it.
Window is BACKUP_JOB_WINDOW_HOURS (default 36 -- longer than a daily cycle so
one missed run does not hide a failure). A node whose task log cannot be read
is reported, never assumed healthy.
|
||
|
|
5be25be081 |
fix(backups): stop paging on guests that are deliberately not backed up
ana-scale (CT 114) is a subnet-router LXC, excluded from vzdump on 2026-09-07
after a backup lock on its ESH counterpart blackholed that entire site. The
freshness check knew nothing about that and reported it 🔴 STALE every single
morning, which is how an alarm teaches you to ignore it.
Such guests now get their own section: printed every run, never hidden, and
not counted as a fault.
The subtlety is in how coverage is computed, and the obvious implementation is
wrong twice over:
* Reading one job's `exclude` list gets ana CT 109 (ana-nas) exactly
backwards. It IS excluded from the 03:00 all-guests job AND it has its own
dedicated 22:00 job. Suppressing on the exclude list would have stopped
alarming on a guest that is genuinely backed up -- trading a noisy alarm
for a blind one.
* ESH's job uses an explicit `vmid 100..107` INCLUDE list, so esh-scale 108
is excluded by OMISSION and appears in no exclude list at all.
So coverage is a union across every enabled job on the cluster, and a guest is
"intentionally not backed up" only when none of them covers it.
If coverage cannot be read, nothing is suppressed and the gap is reported: an
unreachable PVE node means we do not know, and a backup alarm must fail loud.
The SureFire namespace is never consulted (tenant property), so its guests can
never be suppressed either.
Verified against the live fleet on all four paths: CT 114 suppressed; CT 109
NOT suppressed despite being in an exclude list; esh-vm-workstation 102, which
a job really does cover and which really is failing, still reports STALE; and
with a PVE node made unreachable, 114 returns to STALE with the gap named.
|
||
|
|
e979ccb337 |
fix(backups): the freshness alarm had no wire — reconnect it and make it testable
The daily backup-freshness check has been unable to raise an alert since the
2026-08-28 althing v3 cutover. It called althing-cli, which v3 DELETED rather
than deprecated. The check itself never stopped working: it detected three
stale backups every morning and told nobody, and the only trace was a WARN
line inside a unit that was already reporting `failed` for the stale backups
themselves. Three weeks, silent.
Four changes, because swapping the binary alone would have left it dead:
* althing-cli -> postbox.
* Add ALTHING_POST_OFFICE to the systemd user unit AND to the installer that
writes it. postbox has no default address by design and a user unit
inherits nothing from the interactive shell, so the binary swap on its own
would have failed with a different message. Fixing only the live unit
would have been undone by the next installer run; the two are now verified
to agree.
* Recipient infra-ops -> infra-hermes. This runs AS infra-ops, so the old
address mailed the alarm to itself — the mirror trap named in CLAUDE.md.
Day-to-day checks are infra-hermes's half of the split; he escalates.
* Split the exit codes. 1 now means "backups stale, someone was told";
2 means "the alert path itself failed". A broken alarm is a worse fault
than the thing it watches and must not be indistinguishable from it.
Adds --test-alert: a positive control that sends a real message through the
real path on demand. The wire was cut for three weeks precisely because
nothing ever exercised it in the healthy state, and an alarm whose success
path is never run is not known to work.
Verified: positive control delivered; missing-address and unreachable-post-
office both correctly exit 2; a real run through systemd delivered the alert
and exited 1.
|
||
|
|
ffe7b24935 |
feat(ops-log): attribute host changes across two agents sharing one identity
infra-ops and infra-hermes act as the same OS identity and dockerd does not
log exec per caller, so host-side changes carry no fingerprint. Git cannot
close the gap either: every commit here is attributed to Vuong Hoang by
convention, which is correct for authorship and useless for attribution.
On 2026-09-18 a second session edited the searxng stack mid-deploy, crash-
looping fleet search for ~4 minutes, and the author was unidentifiable.
scripts/ops-log records one line per host-changing action and holds a
lightweight claim so two agents do not deploy the same stack at once.
Four design questions, settled:
* Central on nh3-dev, not per-host and not the post office. Both agents
run as the same unix user there, so one file is shared with zero
provisioning. Per-host needs a writable path on ~25 heterogeneous boxes
and stores "we changed host Y" on host Y. journald looked free but shows
an unprivileged reader only their own _UID, which would have split the
log silently between the infra-ops and lkraven halves of the fleet.
* The claim is advisory and enforced in the tooling. deploy-stack.sh
refuses a foreign claim across the diff, the prompt and the apply -- the
whole review window, which is where the collision happened. Acquire is
mkdir, so it is atomic rather than probably-fine. Stale claims auto-break
and the break is recorded.
* Writers are automatic. deploy-stack.sh and elway record themselves; a log
that depends on remembering is the same class of instrument as a health
check that passes in both states.
* There is a detector. `ops-log audit` asks each host what changed on disk
and compares it to the newest log line for that stack, covering the
manual ssh-and-edit path the automatic writers structurally cannot.
ops-log being absent or broken never blocks a deploy; only a live foreign
claim does. `ops-log baseline` marks the 136 stacks that predate the
instrument so the detector starts from today rather than reporting the whole
fleet forever and training us to ignore it.
An unreachable host reports INCOMPLETE and exit 5, never clean.
|
||
|
|
4d826e17e3 |
memory: infra-hermes is infra-ops' assistant, and the ops log is assigned
Operator ruling 2026-09-19, recorded in three places because each serves a different reader. CLAUDE.md gets the structural facts so a fresh session has them without reading anything else; persistent-memory gets the dated decision and the assigned work; auto-memory gets the durable working relationship. The division: infra-ops keeps improving infrastructure tooling plus the hard calls, infra-hermes takes day-to-day checks, triage and routine operations, either may perform infra ops, and infra-ops may task him downward while he escalates upward. He is explicitly NOT Miranda. The global CLAUDE.md names Miranda as the sole trusted relay of operator authority and that exception does not extend to him, so a directive he relays is information rather than authorization — reversible relayed work executes, irreversible or fleet-affecting goes to the operator. He has acknowledged it in those terms. ⚠ The two handles differ by one character in the middle of a word and the fleet's OS identity is infra-ops, so a misaddressed page still mails the sender themselves. That trap is now documented alongside the existing mirror warning rather than replacing it. Building the ops log is assigned and not started. Two agents now share one fingerprint-less OS identity: ssh infra-ops@<host> is either of us and dockerd exec is not logged per-caller. The precipitating incident is on the record — 2026-09-18, a second session edited the searxng stack mid-deploy, crash-looped fleet search for ~4 minutes, and the author was unidentifiable because every commit is attributed to Vuong Hoang by convention. The parked attribution-gap memory is unparked and points here. The open design questions are noted as mine to settle, the load-bearing one being whether deploy-stack.sh and elway write to the log automatically. A log that depends on remembering is the same class of instrument as a health check that passes in both states, and this repo spent yesterday learning what those cost. |
||
|
|
148a5a34da |
memory: snapshot — three silent fleet faults found and fixed in one afternoon
An infrastructure day with no training work, and the through-line is that every fault was invisible to monitoring. NH3↔Anaheim had been crossing a throttled DERP relay rather than a direct path for long enough to carry 78 GB; `.internal` DNS was failing roughly one lookup in ten from two independent causes; SearXNG had exactly one working general web engine. Nothing alarmed on any of it. All three surfaced because tts-dev had a 1545 ms voice-loop budget and chose to measure rather than adapt around the problem. Also landed: althing v3.6.3, which makes hyphenated search work for the first time on a fleet whose hostnames are nearly all hyphenated; FleetTools, a capability index autoloaded by Claude, Codex and Grok from one symlinked file; Miranda's Hermes plugin moved from a copy to a repo symlink; Worldtree's env.sh secrets vaulted. Six detail files. The in-flight section is rewritten and shrinks 142 lines to 64 — it opens on the one thing this session did NOT verify, lv-mccarthy's run outcome, which was left untouched and must not be assumed good. Two foot-guns recorded, both mine: the ESH egress experiment reverted on a diagnosis the rollback itself falsified, and `!ENV` in searxng settings, which has no constructor in that build and crash-looped the container ten times. No archival this run. 165 of 169 dated entries are under the 14-day guard and the remaining four all carry open deferred pointers, so the index stays over the soft cap at 480 lines — an over-cap file that keeps live decisions beats a scannable one that lost one. |
||
|
|
5a9fad8240 |
docs(mesh): record the ana-gw port-forward that ended the NH3↔ANA relay
Every NH3→Anaheim flow had been crossing Tailscale's LA DERP relay rather than a direct path, for long enough to have carried 78 GB tx on the NH3 side alone. DERP is a throttled fallback, so this imposed both a fixed round-trip penalty and a bandwidth ceiling on LiteLLM, Beszel, task-board, vor and the Henge alike. It surfaced as a voice-loop latency report from tts-dev, not as a network alarm, because nothing monitors whether a mesh path is direct. ana-scale advertised 38.120.12.42:41641 while the Anaheim NAT mapped it to :60798 with no port-mapping protocol available, so inbound hole-punching always failed. Four additive objects on ana-gw give it a stable inbound endpoint. tailscale ping nh3-scale->ana-scale 373-522 ms via DERP -> 6 ms direct STT via the ANA gateway, 96 kB clip 1.399-1.449 s -> 0.237-0.270 s Beszel HTTP nh3-dev->ana-docker 0.94-1.29 s -> 0.014-0.016 s Documents the house template that matters for this box: it runs central-nat, so a policy dstaddr is the real internal address and not the VIP. Also records that the pre-change config was captured with `show` to a local file rather than a tftp job, since this edge has no out-of-band access and a backup is mandatory before touching it. irv-ml1 remains relayed and is called out as outstanding. |