Skip to content

release: promote verified Bitcoin Explorer baseline - #9

Merged
bitcoinuniverseadmin merged 74 commits into
mainfrom
develop
Aug 28, 2026
Merged

bitcoinuniverseadmin merged 74 commits into
mainfrom
develop

Conversation

@bitcoinuniverseadmin

Copy link
Copy Markdown

Outcome

Promotes the repaired and verified Universe Explorer Bitcoin baseline from develop to main before multi-chain implementation begins.

Evidence

  • PR Make the visual gate affordable, and stop the gateway dying under it #8 merged after the address history terminal-state fix
  • frontend unit suite: 182 passed
  • focused visual regression cases: 9 passed
  • backend build, lint, unit, and MariaDB integration checks passed
  • branding, origin, asset, text, gateway, palette, fill, and contrast gates passed
  • production Angular build passed

Release gate

Do not deploy until this PR's full protected check set passes. Capture the current public release identity as the rollback target, then use the atomic release procedure and public synthetic check.

bitcoinuniverseadmin and others added 30 commits August 28, 2026 09:51
Enabling the database made the backend exit at startup. updatePoolsJson
fetches the pools-v2.json sha from GitHub, that fetch must not happen at
runtime here, so currentSha stayed null and index.ts aborted before it
could serve anything.

Bundle the pool list in the repository and identify it by the git blob
hash of its own bytes. That is deterministic, needs no network, and is
directly comparable with the sha already stored in the state table, so a
redeploy with unchanged pool data does no work. The bundled file is the
default now; the GitHub URLs stay for anyone who wants them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rent one

The frontend decided on its own that Mining and Charts were available.
The backend decided separately, from DATABASE.ENABLED and
INDEXING_BLOCKS_AMOUNT, whether to mount the routes behind them. Nothing
compared the two answers, so production shipped both pages against routes
that were never registered and every request behind them answered 404.

Give the two one place to agree on. The route setup records what it
actually mounted, /api/v1/capabilities reports that alongside dependency
reachability, coverage and lag, and startup refuses a configuration that
would advertise a feature it cannot serve. The rules live in their own
module with no imports, so the release gate and the tests judge exactly
what the running backend judges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every $listXX range caught its database error, logged it, and returned an
empty array. The route then answered 200 with [], so a database that was
down was indistinguishable from a chain with nothing to show, and the
Charts page rendered an empty state over a real outage.

Throw instead. statistics.routes already turns a thrown error into 500,
which is the answer the frontend needs in order to tell unavailable apart
from empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Charts page subscribed with one next handler and no error handler. A
404 killed the subscription, so isLoading stayed true, the spinner stayed
on screen, and the range buttons stopped working for the rest of the
session. Every mining widget had the same shape through an async pipe with
an else skeleton, so a failed read left 35 skeletons that nothing would
ever clear.

Add one helper that turns a request into a state machine which always
reaches a terminal state: data, empty, stale, or error. It keeps empty
apart from failed, bounds the wait, retries only what a retry could fix
with backoff and jitter, cancels an obsolete request when the range
changes, and keeps the last good answer to show as stale rather than
replacing real numbers with a blank panel.

Charts, reward stats, pool ranking and the hashrate chart use it. Each
owns its own state, so one failing module no longer decides what the rest
of the page shows. The shared status panel names what failed and offers
retry and system status, and its spinner is drawn from theme tokens: the
one it replaces was white on a light background, which is why a failed
page and a blank page looked the same.

Pool ranking and the hashrate chart also started one request from route
setup and another from the chain tip. They share one trigger now, debounced
so a burst of tips around a new block is one refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last release passed every check and still shipped a Charts page and a
Mining dashboard whose backend routes were never mounted. CI answered from
fixtures, so nothing it ran could have noticed: the thing that was wrong
was the configuration of the machine, and no check looked at that.

release.sh installs beside the running release and refuses the symlink
swap unless the build is complete, the configuration is coherent, the
database answers, the source registry parses with every token present, and
every protocol the registry calls readable has an authority configured.
After the swap it reads /api/v1/capabilities and fails the release if a
feature is enabled with no routes behind it, which is exactly the state
that shipped.

The integration database is pinned to the engine and major version the
deployment runs. It was MariaDB 10.5 against a MySQL 8.4 plan; the
migrations use MariaDB syntax MySQL rejects outright, so a test database
on the other engine would have proved nothing about the release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The product shipped an ultramarine identity that belonged to no other
Bitcoin Universe surface. Core, Wallet and Inscribe are all built on the
same pink-forward system, so the explorer read as a different company's
product wearing our name.

This replaces the brand layer rather than recolouring it. Three roles now
exist where one did: a working pink that is readable both as text and as a
fill, the #ff0066 identity anchor for borders, rings and display type where
it carries no small label, and the lavender that carries everything the
brand pink should not, including focus. Surfaces move to pearl on light and
to the plum-black the rest of Universe uses on dark.

Nothing pink means anything. The five evidence states keep the conventional
green, amber, blue and red, protocol identity keeps its own scale, and the
fee scale is untouched, because a fee band means a fee rate.

Every value here was derived against the contrast module rather than picked:
the anchor is 3.85:1 on white, which is why it is not a button fill, and the
chart series were solved for separation under protanopia, deuteranopia and
tritanopia as well as normal vision.

The gate grew to match. It now measures that the brand is never a state or a
protocol, that two protocols are never the same colour, that dark and high
contrast declare every token light does, and that the retired ultramarine
values cannot come back. Adding it found three collisions that predate this
change: ordinals orange sat 11.1 dE from the amber meaning partial evidence,
and stamps and op data sat inside the new brand family.

331 palette pairings checked, 0 failing.
The page put two chips of equal weight beside each protocol: what the
registry says it implements, and what the runtime snapshot says its
authority can do. So Ordinals, Rare Sats and Runes read "Live, read only"
next to "Authority unreachable", and the summary counted all three among
the protocols readable that day. Both facts were true; neither was the
answer to the question a reader is asking.

Availability now decides the primary label and the count. A protocol whose
authority is unreachable, unconfigured, or still catching up is not
readable, whatever the registry says it implements. The registry capability
follows as a qualifier, and a line underneath gives the evidence: where the
authority has reached, how far behind that is, how many checks have failed
in a row, and when it was last asked.

An authority that is catching up gets its own state rather than being
rounded to working or broken, because that is what an index rebuild
actually is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing zero

A currency with no rate fell through to a multiplier of zero, so every
amount rendered as a confident $0.00. That is the same error as answering a
failed query with an empty list: it turns "we do not know" into a definite
value, and a reader has no way to tell the two apart.

With no usable rate the amount is left blank and titled. This surfaced when
the third-party price feed was switched off, but the fallback was always
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the docs

Search is the product's dominant action and now looks like it: the brand
sweep, a top gloss, and a soft glow, on the one control allowed to carry
them. Hover moves brightness rather than swapping to a second gradient, so
the measured relationship between the fill and its label survives.

The gloss is 0.16 rather than the 0.24 that looked better. At 0.24 a white
label falls to 4.06:1 on the violet end of the sweep, which the gate now
measures: the label is checked against every gradient stop, through the
gloss layer, in all three themes.

The research this came from is recorded rather than asserted. A visual
language pass read each competitor's declared shell colour out of its own
markup and found the category uniform: mempool.space at #1d1f31,
Blockchain.com black, Blockstream dark, and a documented convention of low
chroma and cool temperature. Light, warm and chromatic is an empty position,
and the pass checked whether it is empty for a good reason before taking it.

Two further passes on different strategies produced no new competitor,
capability class, or trend, which is the closure condition.

343 palette pairings checked, 0 failing.
The check rendered LF and read a working copy that this Windows host checks
out as CRLF, so it reported a clean tree as stale and could only ever pass on
the Linux runner. Competitor measurements move to the research doc, where the
branding and origin gates already expect to find a competitor named.
The visual matrix checked overflow, console errors, contrast and
accessibility. A Charts page that never resolved and a Mining dashboard of
35 skeletons passed all four and shipped: nothing overflowed, nothing
logged, placeholders have fine contrast, and axe is content with a
skeleton.

The matrix now measures whether a page finished. With a populated fixture
there is no excuse for a loader still on screen after the settle wait, for
a skeleton that never resolved, or for a chart panel that drew nothing, and
each of those fails the run. With a failure fixture the obligation is the
opposite: a page still waiting must have said why.

Fixtures still cannot prove the deployment can produce data at all, so
synthetic-check.mjs asks the live origin directly. It fails on a feature
advertised with no routes behind it, a range that answers with nothing, a
protocol marked readable whose authority cannot answer, a configured
authority with no checkpoint, and a frontend and backend on different
builds. It runs after a release and hourly.

The backend integration tests now run in CI too, against a real database,
because the migration failure that had to be found by hand was a database
one and no test in the suite touched a database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deployment notes described a database that was off, and the release
procedure was a list of manual steps with no gates. Both are now what the
machine does: a dedicated MariaDB container, why it is MariaDB and not the
MySQL pinned elsewhere, the bounded indexing window and why it is bounded,
the bundled pool metadata, the price feed that is off and why, and a
release that refuses to cut over on a configuration it cannot serve.

The overlay ADR gains the status contract. Capability and availability were
never written down as separate things, which is how they came to be
rendered side by side with equal weight and read as one answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a reason

Three defects the pink found. A loud colour is useful that way: it shows you
where the hierarchy was already broken.

The range selector painted all eleven periods as filled primary buttons and
marked the chosen one with .active alone, so nothing on screen answered
"which period am I looking at". Inside a group of alternatives the unpicked
options now go quiet and the fill means "this is the one". Standalone primary
buttons are untouched, because those are actions rather than answers. This
also fixes the Mempool and Mining switcher and the RBF filter.

The label above a number on a stat card took the brand colour, on 45 of them,
which made the mining dashboard nine pink labels competing with the figures
they introduce. Brand marks identity and intent; a label is neither.

The largest mining pool was drawn in #D81B60, 6.7 dE from the brand pink, so
a pool's identity wore the product's colour. That value and #880E4F are now
held back from the pool ramp alongside the yellow already reserved for the
unknown pool.

Two more things the review turned up. The chart zoom slider was drawn from
the charting library's defaults, a pale blue measuring about 1.15:1 against
the page, on eighteen charts; it is themed once now and spread from there.
And the error detail under a failed panel read "(undefined undefined: )" for
any failure that was not an HTTP response, because the pipe read .status off
whatever it was handed and its null guard came after the property access.
It now answers with something actionable or with nothing.

154 frontend tests pass, 343 palette pairings, 0 failing.
The capability probes and the bundled pool import each catch their own
errors and return a value rather than throwing, so awaiting them is safe.
The lint rule cannot see that without the annotation, and it was right to
ask: the rollback in the pool import was itself an unguarded await, so a
failing rollback would have replaced the error that caused it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mempool depth chart drew its fee bands from the inherited categorical
ramp, which is a rainbow. Applying a categorical palette to an ordered
quantity means the colour tells a reader nothing about whether a band is
cheap or expensive, and one of its bands sat 6.7 dE from the brand pink.

Blocks and the Lens already read this product's fee scale. The depth chart
and its legend now read the same one, so a fee rate has a single colour
everywhere: green for cheap, through amber, to deep magenta for the most
expensive band. The scale is a TypeScript array rather than a custom
property, so the theme is detected from a variable only the high contrast
theme declares, and the answer is cached and dropped on a theme change like
the rest of the chart chrome.

None of this was visible before, because the visual QA fixture reported a
mempool of roughly eighteen thousand vBytes, four orders of magnitude below a
real one. Every y axis label rounded to "0 MvB" and the bands were too small
to resolve, so the most colour-dense surface in the product rendered as one
flat area and its palette was never actually reviewed. The shape is
unchanged; only the scale is real now.

One pairing in the product does not clear the 25 dE the brand is held to
everywhere else: the top of the fee scale sits 10.7 dE from the light brand
fill. Both values are load bearing and neither can move, so it is recorded in
the gate at the distance it actually has. Nudging either one closer now fails
the build.

346 palette pairings, 0 failing. 154 frontend tests.
A gate that never fires is indistinguishable from no gate. The judgment is
exported and covered directly: a spinner still turning on a populated page,
skeletons that never resolved, a chart panel that drew nothing, a page that
is only placeholders, and a failure state that waits without saying why all
fail; a page that finished and a failure state that explains itself do not.

Importing the harness no longer launches it, so the test can reach the
judgment without driving a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clock link, the fee-level filter, and the invert toggle are icons with
a title and no accessible name, so a keyboard or screen-reader user reaches
three stops that announce nothing. The keyboard harness had been reporting
all three; nothing acted on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs it hourly, which only covers the window CI happens to be awake. A
timer on the machine that serves the origin covers the rest, writes to the
journal, and marks the unit failed when a check fails, so an outage between
releases is visible where every other service failure already is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fill rule

The source page exists to say which commit is running. It asks for that over
REST, and that route had no fixture, so the one page whose whole job is to
publish the release rendered with an empty release and an empty backend in
every screenshot of the matrix. Nobody reviewing it could have seen whether it
works.

The design system now also states the distinction the review kept running
into: filling a bar is not carrying a quantity. A progress bar or a
single-series area may be brand pink, because the length carries the value and
the fill is only the mark. Colour that encodes the value stays off limits. If
changing the number would change the colour, the colour cannot be the brand.
Fulcrum is gone from the explorer host: no process, no data directory, no
unit file. The backend was still configured to reach it, and a dead address
backend does not fail loudly, it retries. It was producing roughly two
connection errors a second, forever, which buried every real error in the
journal.

Core answers blocks, transactions and the mempool either way, so the
deployment reads addresses from Core for now and an address lookup fails
immediately rather than hanging. The preflight refuses an electrum backend
when nothing is listening on its port, so this cannot come back quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s room

Two pages carried a 39 KB decorative block graphic beside their heading, one
on the blocks list and one on the transaction test page. Both were drawn for
a dark shell with colours from no palette in this product: navy, charcoal and
an amber gradient sitting on a pearl page. They carry no information, and a
floating decorative shape is the thing this design system exists to avoid.

Removing them also removes 39 of the 93 colour literals the branding
allowlist was carrying, and takes the same weight out of the sprite sheet
that ships in the main bundle.

The block size bar's label is positioned out of flow, so it landed on
whatever followed it. In the blocks table that was the next row, and the
figure was cut in half. The reserve is made only where a label exists.
Five rules used --info, which resolves to the evidence state that means
"informational", as a generic accent. That is what the theme this product
grew from did, because that theme was blue and the accent happened to match.
Under a pink brand they were the only cool text left, and the API docs page in
particular rendered every endpoint header in the colour that means a status
everywhere else.

An endpoint header and a collapsed disclosure are controls, so they take the
brand. A subtitle and a code label are structure, so they take secondary text.
A hover is interaction, so it takes the brand.

The three genuine uses stay: the duplicate transaction alert, the informational
badge, and the proof widget's active marker are all actually informational.
…at it says

The new visual gate found this on its first real run: with every request
held open, the protocols page sat on its skeleton indefinitely. The registry
read had a catchError but no deadline, so a request that hangs rather than
fails left the page waiting with nothing subscribed to clear it. It has a
budget now, and the error state it reaches offers a retry.

The gate's own rule was also wrong for the fixture named loading, which
holds requests open on purpose to photograph the waiting state. Asking that
to have finished asks the wrong question. It is judged on whether the wait
is announced at all, which is what a screen reader needs and what a blank
rectangle fails; the deadline itself is covered by the lifecycle tests,
which run far longer than the harness waits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run pointed at a second output directory committed 21 screenshots and its
report, because only the default directory was ignored. Ignore any output
directory the harness is pointed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Statistics are collected from the moment the writer starts, and nothing
backfills them: there is no first-party source to backfill from, and
inventing rows would be worse than having none. So a 1W range currently
draws a couple of hours of samples under a heading that says 1W.

That is not a lie the chart tells on purpose, but it is one a reader would
take away. When the samples cover noticeably less than the range asked for,
the page says when collection began and that this is all the history there
is. The note disappears on its own as the history fills in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history note read "1 hours ago", and it was a getter, so it reduced
over the whole series on every change detection pass, on a page that takes
a live sample every minute. It is computed when the series changes now, and
it counts in singulars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matrix covers three themes, seven widths, and six data states. It does not
cover forced colours or 200 percent zoom, and both are things this product
claims to support.

Forced colours is the interesting one, because the brand does not survive it
and is not supposed to. The operating system replaces the palette wholesale,
so the question is whether the interface still works once every colour
decision in this repository has been overruled: are controls still bounded, is
text still present, is anything painted on itself. That is a different failure
mode from low contrast and nothing here was looking for it.

Both hold on every route checked, with no horizontal scrolling at 200 percent,
which at a 1280 viewport is a 640 pixel layout.

The socket is not mocked in this check, so the chain strip shows placeholders
and the header reports itself offline. That is stated in the file rather than
left for someone to rediscover: the questions it asks are answered by page
structure, and the data surfaces are the matrix's job.
…it hide

The mining dashboard's hashrate and difficulty panels had no fixture, so half
of one of the thirteen reviewed routes rendered as skeletons and a spinner in
every screenshot the matrix has ever taken. Giving them data made the panel
render, and the panel immediately failed: the difficulty change figure is
#42B747 on the light page, which measures 2.37:1 against the 4.5:1 it owes.

The colour is set through an Angular property binding,
[style]="up ? 'color: #42B747' : 'color: #B74242'". The colour gate's markup
pattern required a literal style attribute, so a binding that paints exactly
the same way was invisible to it, and had been for as long as the gate has
existed. The pattern now matches [style], [ngStyle] and [attr.*] too.

Green still means up and red still means down. They are the evidence state
tokens now, which are the versions that clear the floor on every surface.

Mining now reports 0 contrast failures and 0 accessibility violations across
light, dark and high contrast, where before it reported nothing at all.
A restore was verified by hand, which proves the dump format works and
nothing else: there was no backup being taken. A daily timer now writes to
the same directory and keeps two weeks. It refuses to keep a dump that is
suspiciously small and checks the archive reads back, because a backup
nobody verifies is not a backup.

The deployment notes gain the measured growth figures, so the next person
deciding whether to prune or to widen the indexing window has the numbers
rather than a guess. At roughly 300 MB a year against 1.5 TB free, nothing
is pruned, and statistics are kept indefinitely on purpose: the "all" range
is the whole series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notes still told the reader to compose a dump command by hand, which
was accurate before there was a timer taking one every day. They now name
the unit, and they say to verify a restore rather than to trust that a dump
exists, because that is the part that was actually missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bitcoinuniverseadmin and others added 17 commits August 28, 2026 17:53
Universe Glam: the pink brand system, and the gates that keep its roles apart
The inherited about routes only proxy a hosted mempool API. This deployment
configures none, so a request to them could answer nothing but 500 after a
doomed outbound attempt. They are now mounted only when that API is actually
configured, which makes them 404 like every other route this deployment does
not serve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every combination of thirteen routes, seven data states, three themes and
seven widths is nineteen hundred page loads. That does not fit the job
timeout, and one run this morning was killed at exactly sixty minutes. A
gate that cannot finish protects nothing.

The broad pass walks every route at a phone and a desktop width with real
data, which is where overflow, broken images and a page that never finishes
show up. The deep pass takes the gated routes through every failure state,
every theme and the widths that change the layout, which is where a page
that lies about why it is empty shows up. Measured at two and a half
minutes and sixteen minutes against the built frontend.

A failure fixture also no longer waits the full settle budget. It has
nothing to fetch, so it reaches its terminal state almost at once, and
waiting fifteen seconds on each of those was most of what made the sweep
unaffordable.

The harness now stops with a plain message when the server it is measuring
goes away, instead of recording every remaining route as a broken page and
sending the reader to look at the application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…odes check follows the port

The failure settle budget is four seconds, but the dashboard said nothing
until ten and the blocks list retried for six. The deep pass passed anyway
only because the axe and contrast probes delay the settle loop past those
deadlines, which is a machine-speed race, not a margin. The dashboard now
speaks at five seconds and the blocks list reaches its terminal state inside
four, so what the gate measures no longer depends on how fast the runner is.

The forced-colours check still pointed at the fixed port the serve step no
longer uses; it follows the per-run port now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chain note, the registry version line and its inner span took their
colour from the legacy --transparent-fg. It is rgba(36,26,43,0.62), which
composites to about #77717c on the page surface and measures 4.33:1 against
the 4.5:1 normal-text floor. The rest of this component already used
--u-text-muted, which clears it.

Six text declarations move to the muted token. The two border declarations
keep the translucent one, because a border is not held to the text floor.

Found by the broad visual pass against a verified-fresh build. Worth saying
how it stayed hidden: an earlier run measured a stale bundle and reported a
different, non-existent failure instead. On this machine `pkill` does not
kill node, so orphaned gateways accumulate and one of them keeps serving an
old dist. Compare the styles hash the server returns with the one in
dist/index.html before trusting a local visual result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My previous commit reverted adbe39a: the gateway port in the modes-check
step went back to a literal, the blocks-list backoff went back to two
seconds, and the dashboard first-data timeout went back to ten. None of
that was intended and none of it was in the diff I meant to make.

Cause, for the second time in this branch: this worktree and another share
one branch. A commit made in the other worktree advances the shared ref, so
HEAD here moves while the files here do not. `git add -A` then reads the old
bytes as a deliberate change and commits the peer's work away. `git status`
does not warn, because from its point of view the tree really has changed.

Stage the files you edited by name in a shared-branch worktree. `git add -A`
is not safe here, and neither is trusting a clean-looking status after a
merge that reported nothing to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oduction-go-state

Make the visual gate affordable, and stop the gateway dying under it
The consolidation that moved this out of the dashboard restored the ten
second value that adbe39a had deliberately tightened. At ten seconds,
whether the visual gate saw the timeout panel depended on how slowly the
probes ran on that machine; five keeps the page's terminal state inside the
gate's failure budget by design rather than by latency. The first socket
payload normally lands within two seconds, and a late arrival still clears
the panel, so the tighter deadline costs a healthy deployment nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

develop gained b314061: the live-feed deadline is restored to the five seconds PR 8 reviewed (the stream consolidation had brought back ten, which made the deep visual pass a machine-speed race on home chain-down). One line plus its rationale; 182 frontend unit tests pass. CI restarts on the new head; this PR now promotes b314061.

The artifact a release installs was built by hand on a Windows workstation.
That machine has two ways of handing you a stale build without saying so.
A leftover gateway process keeps serving an old output directory, and pkill
in Git Bash does not kill a Windows node process, so those accumulate. And a
build step that fails leaves the previous output sitting where a reader
expects the new one. Both have already produced a measurement that described
a build nobody was running, once as a false regression and once as a real
one that went unnoticed for a while.

A runner starts from a clean checkout, so the artifact matches the commit it
names. Dispatch it with the ref to build; it prints the resolved sha.

It repeats the four bundle gates rather than trusting the CI run on the same
commit, because that was a different build. node_modules and the compiled
gbt module are not packed, since install hard-links them from the running
release, which is only correct while the dependency tree is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread .github/workflows/universe-release-artifact.yml Fixed
Comment thread .github/workflows/universe-release-artifact.yml Fixed
Comment thread .github/workflows/universe-release-artifact.yml Fixed
Comment thread .github/workflows/universe-release-artifact.yml Fixed
bitcoinuniverseadmin and others added 8 commits August 28, 2026 18:23
…king

Two fixes to the release artifact workflow I added in 078f5c9.

CodeQL was right to fail it. The ref input meant that dispatching from the
default branch would check out an arbitrary ref and run its install and build
scripts while holding the default branch's Actions cache scope, which is the
scope universe-ci.yml restores from. That is a real path from "run a build of
my branch" to "poison the cache every other job here trusts", and it is worth
more than the convenience. The workflow now builds whatever ref it was
dispatched against, which is what this was always going to be used for: the
file has to exist on the ref being built anyway, because a dispatch executes
the workflow as it exists there.

The Pack comment also promised a dependency check that was not written. The
check belongs in release.sh install rather than in the workflow, because that
is where both trees are visible: the artifact already carries the backend lock
file, so install now compares it against the running release and refuses the
hard link when it differs. Hard linking a stale node_modules into a release
that asked for different packages gives you a backend that starts, imports the
wrong versions, and fails somewhere unrelated later.

Reported by mempool-3d in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend and the overlay restart behind the gateway, and the gateway
bridges that gap. A restart of the gateway itself could not be bridged: the
port went away with the process and the edge answered 502 until it came
back. release.sh already limited that to releases which change gateway.mjs,
which is honest but does not help, because this release changes gateway.mjs.

The gateway now accepts a listening socket from systemd on fd 3 and falls
back to opening its own port when there is no handover, so it runs the same
way under a unit, in CI and from a shell. universe-explorer-gateway.socket
owns the port and the service drop-in requires it, which is what makes a
restart leave the port bound: connections arriving mid-restart queue in the
kernel backlog instead of being refused.

The pid check on the handover is the part worth reading twice. LISTEN_FDS
and LISTEN_PID are inherited by child processes, so a child that trusted
them would listen on a descriptor belonging to its parent. Covered, along
with the no-handover and malformed-handover cases, in gateway.test.mjs.

Adopting this on a gateway that is already up costs one brief interruption,
because the running process holds the port without SO_REUSEPORT and the
socket unit cannot bind underneath it. That is worth spending once here,
where a gateway restart was going to interrupt things anyway. The cutover log
now states which of the two cases applies rather than leaving it to be
assumed.

Three other universe services on this host are already socket activated, so
this follows the existing pattern rather than introducing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ervice

PartOf propagates stop and restart from the named unit to the one declaring
it. With PartOf=universe-explorer-gateway.service on the socket, restarting
the gateway would have restarted the socket too: service stops, socket stops
and drops whatever was queued in the backlog, socket rebinds, service starts.
That is a shorter unbound window than before and it is still an unbound
window, so the comment in the unit and the success line in release.sh would
both have been claiming something the units did not deliver. docker.socket
carries the same declaration, which is why restarting docker.service is known
to bounce its socket.

Removing it is the whole fix. The service keeps Requires and After, so
starting the service still brings the socket up first, and restarting the
service now genuinely leaves the port bound.

What this gives up is that stopping the service alone no longer takes the
port down, because the socket outlives it and the next connection starts the
service again. A deliberate full stop means stopping both units, which is now
written in the unit and in DEPLOYMENT.md rather than left to be discovered.

Reported by mempool-3d in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The socket unit introduces a way for a rollback to be the thing that takes
the site down. A release from before the handover opens port 8099 itself, so
if systemd is holding that port, that gateway dies on bind with the address
already in use. The rollback path is reached precisely when something has
already gone wrong, which is the worst moment to find a second failure.

cmd_rollback now checks whether the release it is rolling back to understands
the handover, and disables the socket first when it does not. That check is
the reason inheritedListenerFd is an exported name rather than a local one:
it is greppable in a release directory.

Also reformats the dependency guard added earlier. Both guards were written
with backslash continuations that arrived collapsed onto one line, which
parses and behaves identically but reads badly at 187 characters. Nested
tests instead. While reformatting I clobbered the cutover logging block with
the wrong replacement and restored it from origin; the block is unchanged.

Tested by extracting the guard from this file rather than retyping it: an
older target disables the socket, a newer one does not, and neither happens
when the socket is not in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…red at

Two things found by using this stuff rather than reading it.

synthetic-check took a positional origin while everything else in this
directory takes --base. Passing --base was not rejected: it was read as the
origin, and failed several requests later with a URL parse error naming a
string nobody typed. It now accepts either spelling and rejects anything else
outright. This runs on the verification path after a cutover, which is not
where an operator should be debugging argument parsing.

DEPLOYMENT.md now carries the measurement behind the socket activation claim
rather than asserting it: on a spare port on this host, with a throwaway unit
pair since removed, a full restart under a 20ms probe returned 200 on all 400
requests while the pid changed underneath. It also states the rollback hazard
next to the rollback instructions, because release.sh handling it
automatically is not a reason for the operator not to know about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frontend job had an hour. The deep pass alone measured 48 minutes once
the gated route list went from three routes to seven, and the job was killed
three minutes from finishing, having already produced a green broad pass and
every bundle gate. Nothing was wrong with the code; the budget was set when
the pass was a third of its current size.

A gate that cannot finish protects nothing, which is the reason this matrix
was split into a broad pass and a deep pass in the first place. Setting the
budget to the measured cost of the pass that actually runs, with room to grow
before it needs revisiting again.

Seven routes across every failure state, three themes and three widths is
441 page loads. If it grows again the answer is a matrix over routes rather
than a larger number here, since wall clock is what makes a slow gate get
skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The zoom sweep failed five routes with 29 to 49 pixels of horizontal
overflow, deterministically, on both runs of the same commit. The pages are
not at fault. The measurement is.

It set zoom: 2 on the root element. That scales the layout box without
changing what media queries see, so the desktop breakpoints stay active and
the desktop layout is rendered into half the width. No reader can reach that
state: at real 200 percent zoom the CSS viewport halves and the responsive
rules answer to the smaller width.

Measured on a page with one min-width: 992px rule holding a 900px element.
Under zoom: 2 the media query still matched, the element was still
displayed, and the document overflowed by 260 pixels. At a real 640 viewport
the query did not match, the element was hidden as designed, and overflow
was zero.

So the sweep now uses a 640 by 450 viewport at device scale factor 2, which
is what the browser does at 200 percent. Verified against the live origin
with the same fixtures: all ten checks pass, zero overflow, zero text painted
on itself. That is the deployed build, which does not carry this release's
layout work, so the result is about the method rather than about the fix.

Consistent with the route matrix, which measures document overflow the same
way at 320, 768 and 1440 and reports none. A gate that fails a state the
product cannot be in teaches people to ignore it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bitcoinuniverseadmin
bitcoinuniverseadmin merged commit f1d51eb into main Aug 28, 2026
10 checks passed
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Release record, final. Production serves main f1d51eb: frontend, backend and RELEASE-SHA agree, overlay backend-apis-f6bf1104, all units plus the gateway socket active, journals clean. 16 of 16 synthetic checks pass on the public origin. Post-cutover visual pass: every counter zero across 13 routes at 375 and 1440, against 6 contrast failures and 12 axe places on the same origin before the cutover. Cutover cost one measured 3.1 second gap, the pre-agreed one-time socket adoption; a subsequent gateway restart on the live origin under the same probe refused zero connections across 534 probes with the pid replaced. Observed through block 964484 arriving and serving with full data. The edge favicon and robots hijack found in the sweep is fixed at the explorer docroot with the main-domain redirect untouched. Deferred follow-ups are recorded in this PR's threads: duplicate push-plus-PR heavy runs, the zoom-200 tx body-chars floor margin, DEPLOYMENT.md notes for the edge trap and the release-tool refresh rule, and CI coverage for direct develop pushes while no PR is open. develop and main are synchronized at f1d51eb locally and on origin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants