Commit Graph

35 Commits

Author SHA1 Message Date
efbe901646 feat: section indexes, so the routes can be found
All checks were successful
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Every route added lately was reachable only by already knowing an address — a
block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody
sent you, useless for discovering the pages exist. A kind with nothing after it
is now the index of that kind, and a nav under the chain chips names them.

Not the same page three times. Blocks is the record behind the live ticker,
paged by height. Accounts ranks by tokens earned over the whole indexed record,
which is a different question from the standings' blocks-per-window and here a
different answer: difficulty rose 346-fold inside mainnet's first day, so an
early block cost a three-hundredth of a current one. Runtimes is the only list
with no other home — a block does not name its runtime and neither do the
standings.

No `/:chain/miner`: the standings already are the index of miners, and a second
page of the same rows under another address would be two answers to one
question. That path redirects there, along with anything else that resolves to
the front page without being spelled like it, so the address bar and the section
nav agree about where the reader is.

Two things caught by looking at the rendered pages:

The block index's gaps came from `observed_at` and read *three milliseconds*
between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md
records, since a gap fill writes a whole batch within one second. They come from
`authored_at` now, the chain's own clock.

The first page of that index came from the live ticker, which is ordered
oldest-first because that is the order it is pushed in, so page one climbed and
every page after it descended. The index sends a cursor for its first page too.

Accounts shows the address beside any node name: one node legitimately reports
for several payout addresses, and the first cut had two rows reading
`pearl-prover` with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 18:07:11 +03:00
289a80a7eb fix: a single-runtime chain lost the one fact worth having
All checks were successful
deploy / build (push) Successful in 7m19s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
`discover_runtimes` seeded its map with two plain inserts — the runtime at
block 1 and the runtime at the tip. On a chain that has only ever run one, both
probes report the same `spec_version` and the second insert overwrote the
first, so the recorded height was the tip rather than block 1.

Mainnet therefore read "in force from #13,501", which is where this observer
started watching and not where the runtime started. Heisenberg hid it: its two
ends are different runtimes, so nothing was overwritten and all six boundaries
came out right.

Both call sites and the recursion now go through one `note` helper that keeps
the lowest height seen. That is the invariant the whole search rests on —
bisection meets a runtime inside its reign before it finds where the reign
began, so a probe can only improve the bound and none may worsen it — and it
was previously written out twice, correctly in the recursion and not at the
seed.

`lower_first_seen` takes the minimum, so the wrong row corrects itself on the
next discovery pass rather than needing a migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:57:02 +03:00
b006f54cd5 feat: a runtime's own page
All checks were successful
deploy / build (push) Successful in 7m12s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
`/quantus/runtime/152` — every pallet, call, event, error, storage entry and
constant the runtime declares, with the constants' values decoded against their
own declared types. Nothing transcribed from a source tree: a source tree says
what the chain should be running, metadata says what it ran.

The set of runtimes is found rather than waited for. Decoding caches one the
moment it needs it, so left alone the cache is "whatever the backfill has walked
past" — days on a chain a million blocks deep. `spec_version` only increases, so
the boundaries are a sorted sequence and bisection finds each in log₂(height)
probes. On Heisenberg that turned up six runtimes and the block each took over
at: v126 from 1, v128 from 132, v131 from 342,813, v136 from 669,129, v144 from
812,055, v148 from 977,079 — two more than the four upgrade boundaries this
approach was originally verified against.

Discovery is its own task, not a poll step. `state_getRuntimeVersion` at an old
block makes the node instantiate the runtime WASM from that block's state:
~4 s against Heisenberg's endpoint versus ~0.25 s at the tip, and a hundred of
those inside a four-second loop stalls difficulty and the summary broadcast for
minutes on every start.

Reading Heisenberg's two ends side by side is the case for the page. Between
v126 and v148 the chain dropped Referenda, ConvictionVoting, Recovery, Assets
and AssetsHolder, added Vesting and Origins, gained `WeightReclaim` and moved
`ChargeTransactionPayment` after the two Quantus-specific extensions. Every one
breaks a decoder written against the other version and none is visible from a
block.

`U256`/`U512` now render as one decimal rather than four or eight little-endian
limbs, identified by registry path exactly as `AccountId32` already was.
Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` describes the bytes correctly and
tells a reader nothing. `ChainSummary` gained `spec_version`/`spec_name` so the
footer can say what the site is decoding against, and link to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:45:28 +03:00
1260cc6d64 ui: the block route is a page too
Same reasoning as the miner and account routes one commit ago, which the block
route was deliberately left out of: a block is read against the standings, and
the ticker row below is what you clicked to get here. On reflection that is an
argument about how someone arrived, not about what the URL names — and the URL
names one block. The board is what `/:chain/:window` is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:44:58 +03:00
1410d3dda7 ci: skip the Rust build and API deploy on frontend-only pushes
All checks were successful
deploy / build (push) Successful in 7m25s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 15s
The musl release build plus the gate is most of the wall clock, and a commit
that touches only `web/` cannot change a byte of it. A `what changed` step
diffs against the previous head and sets one output; the gate, the build, the
ts-rs drift check and the whole `deploy-api` job hang off it. Not
`on.push.paths`, which would skip the entire workflow — the site still has to
build and ship.

Anything unrecognised counts as Rust. A false positive costs a slow deploy; a
false negative leaves a binary on bob that does not match the commit, and
nothing would report it. Two entries in the path list are less obvious than
they look: `asset/`, because deploy-api ships the systemd units, the firewalld
service and the rendered config from it; and `web/src/api/generated/`, because
the drift gate only runs once `cargo test` has regenerated those files, so a
hand-edit of them must not be able to arrive labelled frontend-only — that is
exactly the change the gate exists to catch.

Checked against this session's five commits: the two pure-UI ones classify as
frontend, the three touching crates or .sqlx classify as Rust, and a
hand-edited generated type classifies as Rust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:23:31 +03:00
e330d2b78a ui: a miner and an account are pages, not dialogs
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Both were panels that opened above the standings, which is right for a block —
"who won this one, and where do they sit" is read against the board, and the
ticker row below is what you clicked to get here — and wrong for the other two.
A miner's page and an account's page are somebody's own record and the whole
subject of the URL that reached them; eleven other miners' rows underneath is
the site failing to notice which page it is on.

The close buttons go with it. Neither panel opened over something the reader
was looking at, so there is nothing to close back to, and the back button and
the chain chip already do what it would. "Mark as yours" stays: that is a real
control and not navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:21:06 +03:00
d6f4c0c21e ui: only advertise chains we can actually read
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
The nav listed every chain telemetry names, with the ones we hold no RPC
endpoint for as disabled chips saying "no endpoint". The reasoning was that
they are part of the network and hiding them misrepresents its shape. In
practice it put five dead chips above the standings on every page — four of
which have never had anything behind them — and made the one live chain the
smallest thing in the row.

Nothing is blocked. `/:chain/...` still resolves for any chain the backend
knows, `/v1/chains` still reports every one of them with its `tracking`, and
promoting one is still a single `[[chains]]` entry. This is only about what the
page offers unprompted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:15:29 +03:00
d5a286f220 feat: account pages, built on the event index
All checks were successful
deploy / build (push) Successful in 7m5s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 16s
`/quantus/account/qzp2AxZw…` answers the question the official explorer
answers badly: what an address has actually been paid, every reward, back as far
as the index has read. Everything comes from this observer's own decoding — no
subsquid, no external indexer, nothing that stops working when the node prunes
the state behind it.

There is no case per event type anywhere in this. `chain_event` gained an
`accounts` column filled by the decoder itself, which knows which values are
`AccountId32` by registry path — the same knowledge that keeps it from
rendering a block hash as an address — so "everything involving this account"
is one containment query that already covers pallets nobody has written yet.
The frontend labels what it recognises and renders anything else as its own key
and value, so a new event type shows up as itself rather than not at all.

Events also carry the block's own timestamp now. `block.authored_at` holds the
same thing but only for blocks this process was running for, and a payment
history that renders "height 412" with no date because the observer was not
alive in March is not a history.

The preimage and the address are two ends of one identity and each page now
links to the other. Address to preimage is a lookup among preimages seen, not a
calculation: the derivation runs one way.

Verified against mainnet: 13,384 reward events indexed from genesis to the tip,
quanpool's page reconciling to 3,784.98 QTC over 12,346 blocks, and transfers
rendering their counterparties as linked addresses without a line of code that
knows what a transfer is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:04:45 +03:00
49c266a905 ui: real URLs for every view
The hash router was the right size for a chain and a window. It stopped being
once a block, a miner and — shortly — an account each became a page worth
sending someone: a fragment never reaches a server, so those URLs cannot carry a
title, be crawled, or be resolved by anything but the page itself.

So paths, via react-router, which was already a dependency and unused. Both
vhosts already answer any path with `index.html`, so nothing outside the client
changed. `lib/routes` holds the grammar in one place and `fromLegacyHash`
rewrites the old form on load, so every `#/…` link already published still lands
where it meant to.

Segments are named — `/quantus/block/13160`, not `/quantus/13160` — rather than
told apart by shape. Shape works until two kinds of thing can look alike, and an
SS58 address is arbitrary base58 with no rule keeping it clear of a window name
forever. Six characters buys the whole class of problem away before accounts
arrive.

Everything navigable is an anchor now, chain chips and the window control
included, which is what "real URLs" has to mean in practice: middle-click opens
another chain's 24h view in a tab, and the back button walks the panels. The
miner panel had no address at all before this — opening one changed the page and
not the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 16:53:41 +03:00
eb26a7bb3c feat: decode events against the runtime's own metadata
A header says who mined a block, not what they were paid: the amount is
remaining supply over an emission divisor plus fees, quantized with dust
carried forward, so only the `MinerRewarded` event knows it. Reading it means
decoding SCALE against the registry of the runtime that produced the block, and
this chain's shapes differ from vanilla Substrate because of the post-quantum
signatures.

So the chain describes itself. `state_getMetadata` at a block hash executes
`Metadata_metadata` against the runtime WASM in that block's state; the v14
registry that comes back drives a decoder that knows nothing about mining,
rewards or transfers. `INDEXED_EVENTS` is the only place a name appears —
adding transfers or anything the chain grows next is one line and a query, not
a migration, a struct and a decoder.

Metadata is cached per spec_version, because a node that starts pruning would
otherwise make history undecodable. Blocks are tried against the tip's runtime
first, which costs no extra call; `decode_events` refuses a partial read, so a
block from before an upgrade fails loudly rather than decoding into plausible
nonsense, and that failure is what asks which runtime actually produced it.

`backfill` walks outward from the tip — to the tip first, then toward genesis —
because live indexing alone leaves holes an account page would show: a restart,
an RPC blip, a gap fill that ran before the metadata was cached. Progress is a
cursor rather than `max(height)`, since a block with no indexed event and a
block never read are otherwise the same answer.

Verified on mainnet: block 13160's preimage derives, through the same Poseidon2
the chain runs, to exactly the `miner` field of its own reward event — so the
header author and the payee join with no lookup table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 16:47:10 +03:00
6a29250c88 feat(ui): link the source repository from the footer
All checks were successful
deploy / build (push) Successful in 6m42s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
The repository is public now. The site's claim is that every miner is derived
from public block headers with no registration and no opt-in; the code doing
the deriving being readable is the other half of that, so it belongs beside the
caveats rather than nowhere.

Its own line under them, not a fourth item in the `space-between` flow, which
would strand a short line against a long one — and it is a different kind of
statement from the three above: where to go and check the numbers, not a
caveat about them. Opens in its own tab so the WebSocket behind the page keeps
its connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:57:31 +03:00
9eb4bee649 fix: a miner's hashrate is the work it did, not its share of today's network
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
Reported against a Grafana panel showing roughly a sixth of what the site did.
Grafana was closer to the truth.

The estimate was `share of blocks in the window × current network hashrate`.
That is only correct while the network is the same size across the whole
window. Measured on mainnet's launch day it was nowhere near: difficulty rose
346-fold inside one 14,400-block window, from 96.4B to 33.4T. A miner that won
its blocks early — when a block cost a three-hundredth of what it costs now —
had that share multiplied by today's network. For this observer's own node the
site reported 25.4 GH/s against an actual 2.5 GH/s.

Difficulty is expected hashes per block by definition, so the difficulty of the
blocks a miner won, summed and divided by the time they spanned, is its
hashrate directly — with no reference to the network's size, and immune to that
size changing underneath, because every block carries the difficulty in force
when it was won. The observer has recorded per-block difficulty correctly since
the parent-state fix, so the data was already there.

`miner_series` had the same flaw and is fixed the same way: summed work per
bucket over the bucket's own duration.

The window's duration is taken from `authored_at`, not `observed_at`. A stretch
this process caught up on carries observation times seconds apart for blocks
minutes apart, and dividing real work by that would invent hashrate — the same
trap the history series was built to avoid.

The headline network figure is unchanged. It divides current difficulty by the
measured interval because it answers a different question: how hard is a block
to win right now, not what did this miner do over the last twelve hours. The
two no longer sum to each other, which is correct and is what the footer now
says.

`standings_rank_by_blocks_then_recency` asserted 400.0 from the old formula;
its third argument is now the window's duration, so it asserts the work-based
figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:45:34 +03:00
17ad5b1f74 fix: a restart no longer empties the ticker and the hashrate
All checks were successful
deploy / build (push) Successful in 6m30s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Reported from the live site: the network hashrate occasionally drops to a
fraction of what it was, the live blocks table truncates to a single row and
rebuilds, and the cycle repeats on an unclear schedule.

The schedule is `blackbeard-api-cert.path`. It watches the host certificate and
runs `systemctl restart blackbeard-api` when it rotates — four times in the
last day, on top of every deploy. Nothing was crashing; `NRestarts` is 0.

What a restart lost was two pieces of memory-only state. The block ticker is
built from live blocks and has no other source, so it came back with one row
and refilled over the next minute. The tip samples behind the measured interval
were dropped outright, because the window replay pushes every row with
`at_tip: false` — correct for blocks a previous process caught up on, wrong for
the ones it genuinely watched arrive, which the `at_tip` column has recorded
since `0002`. Without them `measured_interval` returns `None` and the hashrate
falls back to the configured target: at mainnet's ~1.5s against a 12s target,
an eight-fold drop, correctly labelled nominal and wrong.

Warm start now restores both. It runs after the attributions and carried names
because the ticker rows need their display names. Restored tip samples are
capped at ten minutes old — the interval stays arithmetically valid across a
gap, since blocks kept being produced and both terms grow together, but it
stops being *current*, and a sample from an hour ago averaged with one from now
describes the hour rather than the tip.

The underlying restart is left alone: it is a certificate-rotation safeguard
and not mine to remove. It is now simply not visible, which is what it should
always have been — and worth noting the store already recycles pooled
connections hourly for exactly that rotation, so the restart is belt-and-braces
over a case already handled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:21:25 +03:00
18a99ddc8f fix(ui): head the miner panel with the best identifier, not the preimage
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
The panel body listed the address and preimage, but its heading still read
`0x134e73f0…df59` — the one identifier a miner does not recognise, given top
billing. It now falls back through name, address, preimage, which is the order
a reader can actually use them in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:15:12 +03:00
a3eb670d11 feat: identify miners by the address they are paid at, not the preimage
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
A reward preimage is exact and unrecognisable — no miner knows theirs by sight.
The address is the string they configured and the one their balance shows
against, and the two are related by a derivation the chain performs anyway:
`pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address`, which is
`qp_poseidon_core::rehash_to_bytes`. Running the same function reproduces the
address exactly, with no RPC and no lookup table.

The derivation takes the preimage and nothing else — no genesis, no chain
parameters — so one preimage is one account on every Quantus chain. The unit
test pins it against a real pair (this observer's own node and the address it
was paid at), so a dependency upgrade that changed the derivation fails the
build rather than quietly relabelling every miner on the site.

Unnamed miners now render as their address. The miner panel shows all three
identifiers, because they answer different questions: the node name is an
inference and may be absent, the address is what the miner recognises, and the
preimage is what the header actually carries and the only one the chain
asserts.

SS58 is written out rather than pulled from `sp-core`, which would bring a
substrate runtime's worth of dependencies to format 36 bytes — the same trade
the readme records for declining subxt. Base58 is hand-rolled because it is
long division and nothing else; blake2 is not, because checksums are not worth
hand-rolling. Both are covered by the address test vector.

`RecentBlock` gains the attribution source: the ticker cannot tell a node name
from an address by looking, since `quanpool-payout-mainnet` and `quantus-radar`
both begin with the letter Quantus addresses do. Two tests that asserted the
old display policy now assert the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:13:25 +03:00
ffcfb79cab feat(attribution): carry a miner's name across chains by reward preimage
All checks were successful
deploy / build (push) Successful in 7m6s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Live telemetry names the miners who win often and leaves everyone else as a
hex string. Measured on mainnet: of 22 miners, 2 were named and 14 had no name
available anywhere — but 6 already had one on Planck, under the same reward
preimage. Those 6 are exactly the small operators the naming is worst for,
because a name needs a block whose first reporter the feed could separate and
they win few blocks.

The preimage is derived from a secret only its owner holds, so the same
preimage on two chains is the same operator. That is an inference the site can
stand behind, unlike guessing from timing — and it is the only lever that works
without waiting on the feed at all.

It is a separate `AttributionSource` and renders as one. `Carried` names carry
no confidence and no votes, because they assert nothing about this chain: only
that this is who the operator was last known to be. Any live attribution here
outranks one, and the row shows an `elsewhere` chip rather than the telemetry
`node` chip so the two can never be read as the same claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:59:29 +03:00
a177e805f4 fix(ui): say what a held name with no current votes actually means
All checks were successful
deploy / build (push) Successful in 6m40s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
A mapping outlives the votes that earned it — deliberately, so a name does not
blink out whenever the feed goes quiet. But the tooltip then rendered "0% of 0
attributed blocks point here", which is arithmetic nonsense dressed as a
statistic.

The top miner takes 87% of blocks at a two-second interval, so its twenty
observations span about forty-seven seconds; votes age out of the window
constantly and this is the ordinary state, not an edge case. It now says the
name rests on evidence older than the current window, which is what is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:50:32 +03:00
992237b03c fix(ui): pluralise the verb, not just the noun, in the attribution tooltip
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Read "100% of 1 attributed block point here" on the live site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:46:44 +03:00
bfd3470840 feat(attribution): offer a best-effort name, and say how thin the evidence is
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
A name now appears as soon as one node leads the vote, rather than waiting for
three. On a chain that resolves a first reporter for about one block in seven,
the old bar left almost every miner permanently anonymous — and since a later
block can overturn a mapping, a guess that corrects itself is more useful than
a blank that never fills in.

What is refused is a dead heat. Choosing between two equally-voted nodes would
be a coin toss wearing a telemetry badge, which is a different thing from a
thin but real lead.

The honesty moves to the presentation instead of being dropped. A percentage
alone cannot separate "100% of 1" from "86% of 7", so `Attribution` and
`LeaderboardRow` now carry the number of votes behind the name. The leaderboard
marks a mapping resting on a single block or a narrow lead with `node?` and a
dashed chip, and the tooltip states both figures and that a later block can
correct it. Dashed rather than a second colour: the accent hue is reserved for
identity and alarm, and this is neither — the same claim held more loosely.

The footer now says node names are inferred from which node reported a block to
telemetry first, never something the miner asserted, and that the reward
preimage beside them is the only identity the chain itself vouches for.

`a_single_lucky_vote_still_names_nobody` asserted the old policy and is
replaced by `one_vote_names_but_says_it_is_only_one`, which pins the new
contract: it names, and it reports `votes == 1` so the UI can mark it. A new
`a_dead_heat_names_nobody` keeps the case that is still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:44:48 +03:00
ef3a4b2689 fix(attribution): a held name survives silence, and only a clear lead replaces it
Some checks failed
deploy / build (push) Successful in 6m31s
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Reported from the live site: a name would appear and then be "often forgotten
if it doesn't mine a new block within the window".

`attribute` dropped the held key the moment the vote window contained no vote
for it. That reads silence as contradiction — the same mistake as counting
abstentions in the confidence denominator, one layer further on. The window
holds twenty observations and mainnet resolves a first reporter on roughly one
block in seven, so an incumbent's votes routinely roll out before any
replacement earns one. The name vanished at that moment and the row fell back
to the raw preimage until three fresh votes rebuilt it.

A held name is now replaced rather than dropped: it stands until a different
node leads it by `TAKEOVER_MARGIN`. The margin is the other half of the same
problem — a window carrying about three cast votes flips on a single vote under
a bare majority rule, and pools demonstrably run several nodes that all report
their blocks (this miner has answered to both `QUANPOOL - quanpool-com` and
`quanpool-payout-mainnet`). Requiring a lead of two rides that out instead of
oscillating between siblings.

Takeover after a restart or rename still works, and is still tested: a node
that genuinely owns the blocks accumulates votes and passes the margin within a
few blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:37:58 +03:00
337dfa0f86 fix(attribution): retry the telemetry join until the feed catches up
All checks were successful
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Names were still absent after fixing the confidence denominator, because no
vote was being cast at all. The cause is that `ATTRIBUTION_SETTLE` was doing
duty for two unrelated quantities.

It was written for one: how long node reports take to spread across the network
once telemetry has the block. Eight seconds is right for that. But the lookup
also has to wait out a second quantity nobody had measured — how far behind the
chain the feed itself runs. On Planck the two coincide, because ~240 nodes at a
13 s block time is ~18 reports a second and the feed keeps up. Mainnet is ~190
nodes at ~1 s, an order of magnitude more traffic, and the feed sits about 57
blocks back. Measured against the node: chain tip 9539, highest height the feed
had reported in the preceding 110 s was 9482.

So every lookup asked who reported a block roughly fifty seconds before
telemetry had heard of it. `first_import` missed every time, every observation
became an abstention, and no author was ever named.

The join now retries every 5 s until the feed reaches the block, giving up at
three minutes and recording an honest abstention then. It adapts to whatever
the lag is rather than assuming it is zero.

Confirmed live: the top miner resolved to `QUANPOOL - quanpool-com` at
confidence 1.00 within about two minutes of restart, from 3 votes cast over 21
blocks. That 3-of-21 is also why the previous commit was necessary and not
sufficient — under the old denominator it would have scored 0.15 against a 0.6
bar and stayed nameless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:12:30 +03:00
057937e9cc fix(attribution): measure confidence over votes cast, not the whole window
All checks were successful
deploy / build (push) Successful in 6m46s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
Mainnet has shown no node names since launch. Every miner renders as its raw
preimage while Planck, on the same code and the same feed, names twenty.

`votes` holds one slot per block checked and a `None` for every block whose
first reporter the feed could not separate from its second. Those are
abstentions — the feed did not say who led — but confidence divided agreement
by `votes.len()`, counting them as votes against. Measured on the live feed,
mainnet resolves a clean lead on about a quarter of blocks, so the best score
any author could reach was 0.25 against a `MIN_CONFIDENCE` of 0.6. No name
could ever be held, however unanimous the votes that were actually cast. Planck
cleared the bar only because 41-87% of its blocks produced usable votes.

Confidence is now agreement among cast votes, which is what it always meant:
"of the blocks where we could tell who was first, this fraction pointed here".
`MIN_VOTES` guards the other side, so a single vote in an otherwise silent
window is not read as 100% agreement.

`a_held_name_survives_a_confidence_dip` asserted `confidence < 1.0` after five
unresolvable blocks. That assertion encoded the bug. It now asserts the
opposite for abstentions and keeps a real dip — disagreeing votes — as the case
that lowers confidence.

Two false leads are recorded in CLAUDE.md so the next person does not spend the
afternoon on them: the feed delivers a 190-node roster before any live block
traffic, so `ImportedBlock` does not appear for ~40 s after subscribing and a
short probe concludes the feed is dead; and the roster's `AddedNode` entries
carry a height-0 placeholder block with a zero hash, which inflates the
apparent count of nodes claiming to be first if counted as import reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 13:59:21 +03:00
2cb8f1cf21 config: the node on bob is mainnet now, not Planck
All checks were successful
deploy / build (push) Successful in 7m34s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Mainnet launched today and the node was switched to it. The observer reads
whatever the node is on, so the `[[chains]]` entry pointing at 127.0.0.1:9944
had to follow it — until now the daemon was filing mainnet's blocks under the id
`planck`, with Planck's genesis and PLK still attached to them.

Read from the node, not assumed: `system_chain` "Quantus", genesis
`0xfb5487c0…626fba`, token QTC. `mainnet = true` is asserted here rather than
inferred, which is the field's whole purpose — telemetry also carries a chain
calling itself "Quantus Staging Mainnet", and it is not this one.

The target block time is **12 s, not the 6 s Planck used**.
`TARGET_BLOCK_TIME_MS` is 12_000 in the runtime the node actually runs —
checked at commit b017e642, the `0.11.1-b017e6420aa` its `system_version`
reports, rather than at the tip of the chain repo. It is the denominator for
every hashrate published before twenty tip samples exist, which on a chain
hours old is all of them, so carrying 6.0 across would have put out a mainnet
hashrate at exactly twice its true value — labelled "nominal", not broken.

Planck keeps its 268 telemetry nodes and stays in the site's navigation as
`no_endpoint`: still listed, no longer navigable, because authorship comes from
block headers and we no longer run a node that serves Planck's. That is an
ordinary state here, not an outage.

Nothing about the node itself is touched by this; every command run against it
was a read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 12:07:21 +03:00
53dac3717b feat: block explorer routes, addressed by hash
All checks were successful
deploy / build (push) Successful in 6m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.

`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.

The answer is assembled from two sources because neither is sufficient, and
they fail at different times:

- The node holds the block and forgets it. Non-canonical bodies go once finality
  passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
  ~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
  that sighting was at the tip — and what it has since forgotten: the difficulty
  read while the state behind it still existed.

So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.

Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 16:30:41 +03:00
0ce58094e4 feat(data): record what a reorg displaced
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 18s
`block` is keyed on (chain, height) so a replacement overwrites rather than
accumulates — a schema keyed on the hash would inflate the losing fork's author
forever. The cost is that the losing block vanishes without a trace, and with it
any record that the height was ever contested. `block_displacement` is that
trace: one row per displacement, holding the whole row that was about to be
overwritten.

It cannot be reconstructed later. `blocks-pruning` defaults to
archive-canonical, which discards non-canonical bodies once finality passes them
— about a hundred blocks behind the tip on Planck — and difficulty is a state
read against a 256-block default. Minutes after a reorg the chain itself can no
longer answer for the block, so what is captured at the moment of the swap is
all there will ever be. That is why the row holds the author and the difficulty
rather than just the hash: the author is the interesting field, it says who lost
the race, and it costs a table that gains a row only when the chain forks.

The capture is a CTE ahead of the upsert, in the same statement. Every
data-modifying CTE sees the same snapshot, so it reads the pre-update row even
though the insert below it is replacing that row in the same breath, and a
failure rolls back both — which two statements without a transaction would not.
A trigger would do the same thing invisibly to anyone reading the query.

Nothing reads the table yet, deliberately. The read shape belongs with the
block route that will use it; what matters now is that the history exists by
the time that lands, which it cannot do retroactively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 15:55:07 +03:00
337d1ef516 ci: format the frontend with prettier and gate it
All checks were successful
deploy / build (push) Successful in 6m44s
deploy / deploy-web (push) Successful in 8s
deploy / deploy-api (push) Successful in 15s
`cargo fmt --check` has gated the Rust half since the first commit; nothing
gated the TypeScript, so 25 of 31 files under web/src had drifted from the
.prettierrc that was already sitting in the repo. `pnpm format:check` is now the
counterpart, in the same step as `pnpm lint`.

The reformat is whitespace only — the emitted bundle hashes identically before
and after.

web/.prettierignore excludes src/api/generated. Those files are written by ts-rs
from the entities crate and CI already fails on any diff in them after `cargo
test`; formatting them here would be reverted by the next regeneration and turn
that drift gate red on every subsequent build. Verified by regenerating and
confirming the tree stays clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 15:01:28 +03:00
9cbc2cc274 ui: put the miner count before the height tile
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Height is the one tile with no sparkline — it only goes up, and a straight
diagonal reports nothing — so it sat in the middle of the row with an empty
slot under it, splitting the four lines into two and two. Last, it stops
interrupting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 14:59:26 +03:00
7e5c2de936 feat: sparkline history under the headline tiles
All checks were successful
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
Adds `GET /v1/chains/{chain}/series` and a sparkline under network hashrate,
difficulty, block time and miners. Height gets none — it only goes up, and a
straight diagonal reports nothing.

The chart is bucketed by height, not by time: every point is the same number of
blocks, so every point carries equal statistical weight and a stretch nobody
recorded stays the same width on the axis as one that was. A bucket is the
window over fifty, which makes a full window exactly fifty buckets — that is
what lets the rolling miner count at the last point be the same number printed
in the tile above it, rather than a per-bucket count that would sit well below
it. Buckets are anchored to absolute height for the same reason `miner_series`
bins from the epoch: anchored to the tip instead, every boundary slides by one
on every block and the chart shifts under a reader watching it.

Interval comes from `authored_at`, never `observed_at`. A gap fill writes a
whole batch of observation times within the same second, so deltas taken from
it read as a chain producing hundreds of blocks a second — the trap
`RollingWindow`'s `at_tip` flag exists to avoid, which until now lived only in
memory. It is a column as of `0002`, so the distinction survives a restart and
`observed_at` is interpretable at all.

Each point's interval is a three-bucket moving average. The mean of one
bucket's gaps still carries real Poisson error — 72 blocks of a 15 s interval
lands at ±1.8 s — and across fifty points that draws a chain that appears to
change size every few minutes and does not.

Fixes a wrong number found on the way in: `pallet_qpow` retargets in
`on_finalize`, so `QPoWApi_get_difficulty` at the latest state is the
difficulty for the *next* block, not the one just seen. Every stored difficulty
was off by one block — invisible, because a gradual retarget still looks
plausible. It is now asked at `header.parent_hash`, the same call the miner
made when it built the block. A node that has pruned that state gets `None`
rather than the current value: substituting it is exactly how a gap fill would
stamp today's difficulty across a stretch of old heights. The CLI backfill had
the same bug at greater scale, copying one reading across an entire range.

Unknowns are drawn as breaks in the line, never interpolated: an outage, a
difficulty a pruned node cannot supply, a point without a full window of
authors behind it. A chart that guesses across what was never recorded is the
same class of lie as a hashrate divided by a sync speed, and just as hard to
catch afterwards.

Read against the dataviz skill first, per CLAUDE.md. Single series, one hue;
emphasis on the newest point is `--data-bright`, a lighter step of the same
bronze, because the usual "current period in the accent" convention is the one
change that would break this palette. The hover readout borrows each tile's
note line — five floating tooltips across a row of 180px tiles is a pile, not a
hover layer — and the same readout is on keyboard focus and in the aria-label,
since the line does not start at zero and its height is not a quantity anyone
should be estimating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 14:39:27 +03:00
b552b08b10 docs: record the mesh-bind, hairpin and runner-label gotchas
All checks were successful
deploy / build (push) Successful in 6m44s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Each of these failed a CI run this session and none of them looks like what it
is: a loopback health probe refused by a healthy daemon, a proxy that cannot
resolve its own public name, and 'cargo: command not found' on a node runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:56:42 +03:00
3db86cfc66 fix(ci): health-probe the address the API actually binds, not loopback
All checks were successful
deploy / build (push) Successful in 6m51s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
The probe curled 127.0.0.1:25864 and got connection-refused against a daemon
that was running perfectly — the journal it captured on failure showed it
serving. The API binds the host's *mesh* address by design: the fleet has one
firewalld default zone, so a wildcard bind plus the named service would publish
it on every address the host carries, and the edge proxy is at another site, so
loopback-only would not serve the site at all.

`hostname -f` resolves to that mesh address, so the probe needs no value carried
between steps. Verified against the running service: loopback refuses, the FQDN
returns 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:48:14 +03:00
151a30ae1a ci: run the build on the rust runner, drop corepack, pin the site check to loopback
Some checks failed
deploy / build (push) Successful in 7m0s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Failing after 1m26s
Three things that would each have failed the run seconds in
(architecture/gitea-runners.md §4):

- `runs-on: fedora-43` for a cargo build is `cargo: command not found` — the
  fedora images are node/shell runners with no Rust. `rust` is built on
  `runner-fedora-44`, so it carries node + pnpm as well and one job still builds
  both halves. musl stays: that runner is Fedora 44 and the targets are 43.
- `corepack enable` is not bundled by any image and pnpm is already on PATH.
- The site check curled the public name *from the edge proxy*, where it resolves
  to the site WAN address and dead-ends on the OPNsense LAN interface. Pinned to
  loopback, which still exercises the stream router, SNI, the vhost, the
  cross-site hop and the API.

Deploy jobs stay on `infra`: §4 is about tooling, not routing, and these targets
are mesh-only .internal names — same reasoning as lair/quantus and lair/mail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:37:31 +03:00
36a10ac691 fix(api): allow every configured CORS origin, not just the last
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
`CorsLayer::allow_origin` replaces rather than appends, so folding over the
configured list left only `blackbeard.internal` allowed and silently refused
`blackbeard.observer` — the site's own public origin. It broke nothing, because
the frontend is served same-origin and never consults CORS, which is precisely
why it would have gone unnoticed until something else called the API.

Verified on the deployed vhost: both configured origins are now echoed back and
an unlisted one is refused.

Also records the deployment gotchas this session turned up (exact-argument
sudoers matching, the runas spec for the config check, the cross-site hop the
loopback probe cannot see, and why a WebSocket upgrade test needs --http1.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:21:13 +03:00
6a7253956a fix(infra-setup): escape backticks in the closing message; report db verification honestly
Some checks failed
deploy / build (push) Failing after 6s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
The closing heredoc interpolates $EDGE_HOST, so backticks in its prose ran as
command substitution ('create: command not found').

A missing psql on the app host was reported as an authentication failure, which
sends you looking at the CN mapping instead of at a package that has no reason
to be installed. An unreachable standby is now a loud, specific warning: pg_ident
is not replicated, so a failover to a standby that never got the mapping locks
the app out during an outage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:07:05 +03:00
5f3aac2a13 feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
The chain list is no longer config. substrate-telemetry announces every chain it
knows — with live node counts — before and independent of any subscription, so
one feed connection that never subscribes is a complete, self-maintaining index.
A chain that launches tomorrow appears in the nav on its own.

Config now contributes only RPC endpoints. Authorship lives in the `pow_` digest
of a block *header*, and telemetry publishes hashes and heights but never
headers — so a chain with no endpoint is listed with its node count and is not
navigable, and the nav says why rather than offering an empty board. The two
sets join on genesis hash, never on name.

Two chains are tracked in full: Planck (our own node on bob) and Heisenberg
(a1-heisenberg.quantus.cat, found via Quantus-Network/quantus-apps). Both decode
identically — same digest shape, same QPoW runtime API — which is the proof that
adding a chain is one [[chains]] entry and no new code. Quantus Staging Mainnet
and Quantus Dirac Testnet are listed and disabled: no endpoint exists under the
a1-/a2- pattern and none resolves under any plausible name.

Nav is ordered by node count descending, ties broken on id so it cannot reshuffle
between refreshes.

Telemetry now also yields chain-wide best height, best finalized height, average
block time and the client-version histogram (feed codes 1, 2, 12 and 22).

Infra moves to a single site: oolon fronts blackbeard.observer, reverse-proxying
cross-site over the mesh to the API beside the node on bob. That hop is the one
the loopback health probe cannot see, so the deploy now checks it explicitly —
a firewalld service scoped to bob's own /16 would leave a live site with a dead
/v1 and nothing would fail. infra-setup gains `dns` and `cert` roles that run on
oolon with the credential already there: the Cloudflare token is never copied off
the proxy, and it refuses to repoint an apex record that already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:04:11 +03:00
110fbc3631 feat: blackbeard.observer — live Quantus mining leaderboard
Cargo workspace plus a Vite frontend, following ~/git/architecture/generic.md.

Every block header carries its author's wormhole reward preimage in a `pow_`
PreRuntime digest, so authorship for the whole network is derivable from headers
alone — no indexer, no registration, no way for a miner to be left out. That
decoding, the hashrate maths and the telemetry name attribution live in
blackbeard-core with no I/O at all, so the parts that are easy to get subtly
wrong are exercised by unit tests rather than only against a live chain.

The browser holds one WebSocket: snapshot on subscribe, deltas thereafter. The
head stream is itself a push (chain_subscribeNewHeads), so a block reaches the
page the moment the node imports it. Messages are serialised once per broadcast,
and leaderboards are recomputed only for windows a socket is actually watching.
No RxJS — useSyncExternalStore is React's own contract for this.

Verified against the live Planck testnet: 12/12 headers decoded, telemetry names
attributed (quanpool-planck, baba-gorchitsa, …), warm start restoring 84 blocks
and 5 held names across a restart.

Three findings worth recording, all in CLAUDE.md:

- substrate-telemetry sends its JSON in *binary* frames. A text-only client
  connects, subscribes, reports healthy and receives nothing at all — and a
  Python probe hides it, because json.loads accepts bytes.
- Difficulty is a little-endian U512; decoding it big-endian gives a number
  wrong by ~10^150 that still renders fine.
- Planck's real block interval is ~13-15s against a 6s target with enormous
  variance, so a measured interval needs 20 tip samples before it is publishable.

Deploy assets, the Gitea Actions workflow and script/infra-setup.sh are included;
port 25864 is registered in architecture/port-allocations.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 12:33:54 +03:00