Skip to content

Quarter 3 D2 and D3 deliverables - #65

Merged
tmosleyIII merged 32 commits into
mainfrom
feature/tmosley/network-verdict
Oct 1, 2026
Merged

tmosleyIII merged 32 commits into
mainfrom
feature/tmosley/network-verdict

Conversation

@tmosleyIII

Copy link
Copy Markdown
Contributor

Finish work around UI cleanup and FBAS scanner

tmosleyIII and others added 2 commits September 21, 2026 09:36
Radar had three palettes live at once: Tabler brand colors kept "for
compat" in tailwind.config.js, a Prism token set in tailwind.css with an
unreachable .dark block, and the cream/ink/coral direction from the
network verdict mockup. New UI work (the verdict block) forces a choice,
so make it deliberately rather than by accident.

Promotes the verdict palette to canonical tokens, organised into three
families that stay separate on purpose:

  surface / border / text  the neutral ground, warmed toward the accent
  accent                   brand coral, used sparingly
  signal                   severity only, never decoration

The signal family is new and is what the verdict block needs: a reader
should be able to trust that green/amber/red always means safe/fragile/
at-risk, which only holds if nothing else borrows those colors.

Tabler colors stay, marked deprecated, because ~66 components still
reference them and deleting them now would break the build. The .dark
block is documented as deliberately unreachable: ~900 components still
name literal gray-* values that cannot respond to tokens, so enabling
dark mode today would render half the app in each theme.

Also drives the pulse-ring keyframe from the signal token instead of a
hardcoded emerald, and honors prefers-reduced-motion for the animations
this stylesheet introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PythonFbasAdapter.analyze() returned a hardcoded hasSymmetricTopTier:
false under a TODO. That value is what the frontend's "top tier is not
symmetric, analysis could be slow" warning reads, and because
NetworkAnalyzer.analyzeNetwork() only runs from network-change-queue --
i.e. when a user edits the network in the simulation tools, never on page
load -- the store value stays backend-sourced. Every normal visitor saw
the warning.

python-fbas has no equivalent command (top-tier even rejects --group-by),
so the comparison is done here against the quorum sets Radar already
holds: a top tier is symmetric when every member declares the same quorum
set. Comparison is by order-independent structural fingerprint, so two
nodes declaring the same trust in a different order compare as equal.

Two cases deliberately answer false rather than true: an empty top tier
(the analysis found nothing, which is not the same as symmetry) and a top
tier member Radar holds no quorum set for (unknowable is not symmetric).

To reach the membership, analyzeNodeLevel now returns the top tier
alongside the merged sizes rather than discarding it.

Also trims the per-scan org dump, which stringified every organization
into the logs on every scan, down to a count of the thing it was actually
watching for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 21, 2026 13:44 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 21, 2026 13:44 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 21, 2026 13:44 — with GitHub Actions Inactive
tmosleyIII and others added 24 commits September 21, 2026 10:26
…"0 countries"

Three staging failures, two of them connected.

1. Rollup crashed every scan before persistence.

   RollupMeasurementsError: null value in column
   "historyArchiveCacheMisconfiguredCount" violates not-null constraint

   historyArchiveCacheMaxAge is nullable and is null for every node whose
   archive sent no cache header. Postgres sum() over all-null input returns
   null rather than 0, which the NOT NULL column rejects, so the whole
   rollup -- and with it the scan result -- was discarded. Wrapped in
   coalesce(..., 0).

2. Aggregated quorum sets could require more validators than exist.

   mergeQuorumSets averages the source thresholds but aggregation collapses
   many validators into few groups, so the average routinely exceeds the
   group count. Staging hit the extreme case: geo data lookups timed out for
   all 29 nodes, country aggregation produced a single "Unknown" group, and
   the merged quorum set asked for 7 of 1 validator. Unsatisfiable, so no
   quorum exists, so python-fbas finds no splitting set, so the scan
   recorded splittingSetsMinSize: 0 for both the country and ISP levels.

   That is the "safety threshold of 0 countries" reported upstream by the
   python-fbas author, reproduced end to end in the staging log. Thresholds
   are now clamped to the number of entities that survived aggregation;
   requiring more than exist is never what the operators expressed.

3. One failed level discarded all four.

   analyze() ran the node/org/country/ISP levels through Promise.all and
   returned err if any failed, so a country-level problem threw away good
   node and organization results. Aggregated levels now degrade
   independently; only the node level, which the rest builds on, stays
   fatal.

Zero remains a poor stand-in for "not analyzed" -- downstream it renders as
a threshold rather than as a gap -- so the degradation path logs at error
level and the limitation is documented where the fallback is produced.
Carrying "unknown" properly needs a nullable representation through
NetworkMeasurement and the API.

Also drops a debug block that wrote the full FBAS request to
/tmp/python-fbas-request.json and dumped sample quorum sets on every scan.

The SQL fix cannot be exercised by the unit suite; it needs a scan against
a real database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking "Perform analysis" threw before the worker ever started:

  Uncaught DataCloneError: Failed to execute 'postMessage' on 'Worker':
  [object Object] could not be cloned.

performAnalysis passed four Vue refs to postMessage instead of their
values. postMessage structured-clones its argument, and a ref carries a
dependency graph whose subscriber links hold ReactiveEffect instances with
a `fn` own property. Functions are not cloneable, so the clone threw. The
refs are bound to the analysis-type checkboxes with v-model and therefore
always have subscribers, so this failed every time for every user -- the
tool has never run in this component.

Three fixes:

- .value on analyzeQuorumIntersection, analyzeSafety, analyzeLiveness and
  analyzeTopTier.

- The payload set `id`, but the worker reads `jobId`, so every result came
  back with jobId undefined. Send a real, incrementing job id and drop
  results from superseded runs, matching what NetworkAnalyzer already does.
  Without it a slow earlier run could overwrite a newer one's results.

- postMessage now runs inside try/catch. isLoading was set true before the
  throw, so the failure left the dimmer spinning forever with the reason
  visible only in the console. The error is reset on each run and shown in
  the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
python-fbas reports an organization safety threshold of 2 for pubnet;
Radar reported 3. Neither is wrong. Restricted to the top tier the answer
is 3 -- that is what it takes to split the core. Network-wide it is 2,
because an outlying node can be separated from the core more cheaply than
the core can be split. Radar showed one number without saying which
question it answered.

Both are now computed and persisted. The top-tier figure restricts the
FBAS to the top tier, which is what python-fbas does with
--reachable-from <top tier validator>; here the restriction is expressed
by sending only the top tier members, whose quorum sets by definition
reference each other. Computed at the node and organization levels, which
are the two the verdict block reads.

The statistic is additive, so it never fails a scan: a top tier of fewer
than two entities, a top tier that cannot be resolved back to the request,
and a failed service call all yield undefined and leave the network-wide
answer untouched. undefined is deliberately not zero -- zero renders
downstream as a threshold of zero organizations, which is how the "0
countries" report started. The field is optional on AnalysisMergedResult
for the same reason: the legacy Rust facade does not compute it, and
"not computed" is not "zero".

New columns are additive with defaults, and the v1 DTO fields are optional
and absent from the schema's required list, so existing API consumers are
unaffected.

NOT YET VERIFIED against python-fbas. Before the verdict block ships,
confirm the two numbers reproduce:

  docker run --rm -it giulianolosa/python-fbas:latest \
    --group-by homeDomain min-splitting-set
  docker run --rm -it giulianolosa/python-fbas:latest \
    --group-by homeDomain --reachable-from GCGB2S2KGYARPVIA37HYZXVRM2YZUEXA6S33ZU5BUDC6THSB62LZSTYH \
    min-splitting-set

Expect minSplittingSetOrgsSize to match the first and
minSplittingSetOrgsTopTierSize the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renders the mockup's plain-language readout above the statistics grid:
status, a one-sentence summary, and the three numbers an operator actually
wants -- what halts the network, what forks the core, and what can cut a
node off from it.

deriveVerdict() is a pure function over NetworkStatistics rather than logic
in the template, because the copy is the deliverable here and it needs to
be testable against fixtures rather than only visible by running the app.
16 tests pin the wording, not just the numbers.

Three decisions worth noting:

- "Unknown" is never rendered as 0. A statistic the scan could not compute
  is missing data, not a threshold of zero organizations -- the latter
  reads as the most alarming possible finding. A genuine 0 still displays
  as 0, and a test holds the two apart.

- at-risk outranks fragile. A network that cannot fork but depends on one
  organization is "safe · fragile"; one whose quorums do not intersect is
  "Safety at risk" regardless of its liveness margin.

- An asymmetric top tier is reported neutrally. It is a reason analysis is
  slower, not a fault, and colouring it as a warning would spend the
  signal palette on something that is not a risk.

This is the first consumer of the D3.1 tokens: the component names only
semantic classes (signal-safe/warn/risk, ink-*, surface-card, line) and no
literal palette values, and the build confirms those utilities generate.

Tier 3 of the mockup -- single-points-of-failure chips and fork groups --
is not here. It needs set membership, which D2.1 has not landed. The block
is designed to stand without it.

Also adds docs/local-development.md covering the env files, the two
database gotchas (sslmode=disable, and the Postgres 15 schema grant), and
the fact that jest must run from the repository root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running a real scan caught this. D2.9 was careful to keep "not computed"
distinct from zero through the adapter, and deriveVerdict renders the two
differently on purpose -- then NetworkScan collapsed them with `?? 0` on
the way into a NOT NULL column.

The effect on the first real scan: the Rust facade does not compute this
statistic, so the API served minSplittingSetOrgsTopTierSize: 0 and the
verdict block would have read "Core forks if: 0 orgs collude" -- no
organizations at all needed to split the core, the most alarming statement
the block can make, from an analyzer that simply never answered.

Now nullable end to end: the column allows null, NetworkScan writes null
rather than 0, and the DTO mapper omits the field entirely so API
consumers see an absent value instead of a real-looking threshold.
deriveVerdict treats null and undefined alike, since the value crosses a
JSON boundary where either can appear.

Verified against a live pubnet scan: the field is now absent from
/v1 rather than present as 0.

The migration is edited rather than superseded -- it is unreleased and
exists only on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
withobsrvr/python-fbas:0.1.0 could not be rebuilt. The Dockerfile installs
python-sat==1.8.dev13, which is not on PyPI -- the index goes 1.8.dev12 ->
1.8.dev14, with no dev13 at all. The running image predates that release
disappearing, so production is fine until something forces a rebuild, at
which point the build fails on the last pip install.

The pin comes from python-fbas itself, and it is present in both the
withObsrvr fork and nano-o upstream, so it is worth reporting upstream
rather than only patching here. Moved to 1.8.dev14, the next published
release.

Two other things while in this file:

- The fork install tracked @main, so two builds of the same image tag could
  contain different analysis code. Pinned to bb26cdb, the fork's current
  head.

- Corrected a comment that blamed "upstream" for the private GitLab pyqbf
  URL. That URL is in the fork, which is 80 commits behind; upstream has
  since replaced it with pyqbf==1.1.1.1 from PyPI. Syncing the fork would
  let the whole --no-deps workaround collapse back to a plain install.

Not verified by a Docker build -- no image was built as part of this
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every parser in the service defaulted to 0 or [] when the line it wanted
was missing, so a changed CLI format, a crash, and a genuine "no result"
all arrived downstream as a real-looking answer of zero. Radar renders
those as safety thresholds, and zero organizations is the most alarming
statement it can make.

This stopped being hypothetical when the python-fbas fork was synced with
upstream. Comparing the 80 commits, one of the five scraped formats
changed: min-quorum gained a --mode flag and its default output went from

  Example min quorum:
to
  Minimal quorum cardinality is: N
  Example min-cardinality quorum:

The old parser matched startswith('Example min quorum:'), which no longer
hits, so /analyze/quorums silently returned an empty quorum of size 0.
Contained today only because the adapter reads just quorum_intersection
from that endpoint -- it would not have stayed contained.

Now:

- parse_cardinality and parse_validator_list raise a 502 naming the
  command, what was expected, and the actual output, instead of returning
  a default.
- min-quorum accepts both labels, so the service works against either side
  of the sync.
- Quorum intersection matches BOTH outcomes explicitly ('No disjoint
  quorums found' / 'Disjoint quorums:') and errors on anything else. It
  used to infer safety from one substring being absent.
- "No splitting set found" and "No blocking set found" raise rather than
  reporting zero. No splitting set is not a threshold of zero; that
  conflation is how Radar came to display a safety threshold of 0
  countries.
- A non-zero exit is an error rather than something to parse. The one
  exception is kept and documented: top-tier asserting on disjoint quorums
  is a real finding, not a gap.

10 fixture tests pin each CLI format, including both min-quorum labels and
the grouped (--group-by) unquoted form. A failing test here is the signal
that a sync needs service changes.

Verified against the synced CLI end to end: quorums now returns
example_quorum ["sdf1","sp1"] where it returned [] before, and top-tier
returns a 502 carrying the QBF traceback instead of an empty top tier.

Dockerfile: repinned to the synced fork head c1856af, and python-sat moved
to 1.8.dev30 to match the pin used to build withobsrvr/python-fbas:0.2.0.
1.8.dev13 has been deleted from PyPI entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
requirements.txt pinned pyqbf==1.1.1. pyqbf ships no wheels -- every
release on PyPI is sdist-only -- so pip compiles it, and cmeel runs the
project's bundled CTest suite as part of the build. PyQbfUnitTests fails
there, so the build dies with metadata-generation-failed on the first
pip install. The running 0.1.0 image predates this; nothing could be
rebuilt until now.

1.1.1.1 is the version python-fbas declares in its qbf extra, and the
comment above the install in the Dockerfile already says this file should
track that declaration, so the pin was simply stale rather than
deliberately held back.

Confirmed the version bump alone is sufficient: 1.1.1.1 builds on
python:3.11-slim with no CMAKE_POLICY_VERSION_MINIMUM and no other
change. Verified against the rebuilt image -- /health reports
python_fbas_available true, and the top-tier, blocking-sets,
splitting-sets and quorums endpoints all return correct results.

One trap for anyone rebuilding this with podman rather than docker:
podman defaults to the OCI image format, which silently drops
HEALTHCHECK ("HEALTHCHECK is not supported for OCI image format and will
be ignored"). Build with --format docker to keep it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes to the service image, both about python-sat.

The explicit install moves from 1.8.dev30 to 1.9.dev15. We install
python-sat here at all only because the --no-deps install of python-fbas
above skips its dependencies; 1.9.dev15 is the current PyPI release and
ships a cp311 manylinux wheel, so it stays a wheel install rather than a
source build. python-fbas's own suite passes against it: 189 passed,
3 skipped on python 3.11.

The pinned python-fbas commit moves from c1856af to f5e51c4, which is
the commit that repins python-sat in pyproject.toml. Before it, this
file and python-fbas disagreed about the version and the image was
correct only because the explicit install here happened to win. Now the
two agree, which is what the comment above the install asks for.

PREREQUISITE: f5e51c4 lives on the fix/python-sat-deleted-pin branch of
withObsrvr/python-fbas and is not yet pushed. Until it is, the pip
install of the pinned SHA cannot resolve and this image will not build.
Push that branch before building from this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mote

The Dockerfile pinned python-fbas at f5e51c4, which is a dangling local
commit: the fork's main and origin/main are both still c1856af, and no
remote branch contains it. `pip install git+...@f5e51c4` cannot fetch a
commit GitHub does not serve, so a build from a clean checkout would have
failed -- while building locally kept working, which is the worst version
of this bug.

Repinned to c1856af, which is on origin and is what
withobsrvr/python-fbas:0.2.0 was actually built from (confirmed by reading
the image's layer history from Docker Hub). Move it forward once f5e51c4
is pushed; nothing in the service depends on it, since --no-deps means the
python-sat pin that commit carries is never consulted.

Kept python-sat at 1.9.dev15 -- that is what shipped in 0.2.0. Verified
the service against it rather than assuming: all four analysis endpoints
return the same results as on 1.8.dev14, and the ten parser fixture tests
pass. Noted in the comment that 1.9.x is a minor jump from the 1.8.x line
python-fbas declares, so solver-pinned benchmarks want re-baselining.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verdict block summarises FBAS results in ordinary words. A summary is
only honest if the reader can reach what it summarises, so each line now
opens the existing explainer for the analysis behind it: the headline and
"halts if"/"core forks if"/"nodes cut off by" map to quorum intersection,
blocking sets and splitting sets, and the top tier line to its own.

The mapping lives in deriveVerdict as data, not in the template, so it is
covered by tests and cannot drift away from the copy it explains. Both
fork figures deliberately point at the same splitting-set explainer --
they are two readings of one analysis, and saying so is the point.

Fixed a prerequisite: the four info buttons in the network analysis tool
did nothing. They set showQiInfo / showLivenessInfo / showSafetyInfo /
showTopTierInfo, which nothing read -- the explainer components take no
props and were never bound to them. Another control left inert by the
Bootstrap migration, and it meant the drill-down this deliverable is meant
to pair with was unreachable. They now call the components through
template refs.

To make that possible the four explainers expose show()/hide(). They could
previously only be opened by the global show-modal-global event, which
belongs to the Bootstrap compatibility layer D3 is removing, so exposing
them is also one less thing tied to it.

Explainer buttons carry aria-labels naming what they explain rather than a
bare "info", and take a visible focus ring.

24 deriveVerdict tests, 122 frontend tests, clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audited all 30 components in src/components/ui/ for Bootstrap classes.
The result corrects something I said earlier: the primitives are already
clean. Almost every apparent hit was a Tailwind utility that happens to
share Bootstrap's name (px-4, mb-3, text-left), and Modal.vue's
"modal-header"/"modal-footer" are slot names inherited from the
bootstrap-vue API, not CSS classes. I had called that a leak; it is not.

One primitive did depend on Bootstrap, and it never worked:

- ui/Icon.vue rendered `<i class="bi bi-{name}">`, registered globally as
  UiIcon. Bootstrap Icons' stylesheet is imported nowhere, so the glyph
  could never load -- and no template used UiIcon anyway. Removed, along
  with the bootstrap-icons dependency.

- node-trust-config.vue was the only other `bi-` user, on the "unsaved
  trust configuration changes" warning. Invisible for the same reason, and
  its `me-2` spacing is a Bootstrap 5 class in a Bootstrap 4.6.1 project,
  so it had no margin either. Replaced with an inline SVG.

- components/bootstrap-compat/ (1360 lines) is imported by nothing.
  Deleted.

Left alone, with reasons:

- Bootstrap's CSS is still compiled in via custom.scss, and 66 components
  outside ui/ still use its classes. That is D3.3/D3.4, not this item.

- jquery stays for now. Four components call `$(el).modal("show")` and
  bind `show.bs.modal`, which is Bootstrap's jQuery plugin -- and
  Bootstrap's JavaScript is never loaded, only its CSS. Those modals
  therefore cannot open. Three of them (quorum-set toml export, add
  organizations, add validators) are live via quorum-set-actions.vue;
  consent.vue appears orphaned. Migrating them to UiModal is mechanical
  but changes user-visible behaviour and wants clicking through, so it is
  written up rather than done blind.

Build clean, 122 frontend tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DataCloneError was not fully fixed. Passing .value on the four refs
removed one uncloneable field; `nodes` and `organizations` were still
class instances, and the shared Node/Organization objects are not
structured-cloneable either. The tool still failed on every click.

Found by driving the real UI rather than reasoning about it. Two things
the browser showed that reading the code did not:

- On a healthy network there is no way to open the tool from the
  dashboard. The three links that set isNetworkAnalysisVisible live inside
  warning alerts that only render when something is wrong. The sidebar
  entry is the only route in.

- postMessage reports that something could not be cloned, never what. The
  catch now names the offending fields, which is what located this in one
  run after two rounds of guessing.

The fix removes the clone entirely. The worker fed everything to wasm as
JSON anyway, so the callers serialise once and the worker passes the
string straight through: one less encode, and the failure mode cannot
recur. Applied to both call sites -- NetworkAnalyzer had the identical bug
across its five postMessage calls.

Also made worker failures visible. The wasm calls are synchronous, so
anything they threw escaped the message handler and the worker simply
never replied, leaving the panel spinning with nothing in the console.
The worker now catches and posts an error, and both callers handle it and
set onerror.

Verified with Playwright against the live dev server and real pubnet data:
the panel opens, analysis completes in ~5s, and blocking sets render.
Earlier probes reported no results only because they read innerText, which
skips the v-show'd accordion bodies the results live in.

Playwright needed two accommodations, both in playwright.local.config.ts
rather than the committed config: the default config's webServer command
echoes a hint and exits, which Playwright treats as the server dying, and
`channel: 'chrome'` looks for /opt/google/chrome/chrome, which does not
exist on NixOS.

One silent branch left over from D2.9 now logs: a top tier too small to
split returned undefined without saying so, which made a null downstream
indistinguishable from a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`nix develop` sourced the three .env files with `set -a`, which applies
shell quote removal. A value like

  NETWORK_QUORUM_SET=[["GABC","GDEF"],["GHIJ"]]

was exported as [[GABC,GDEF],[GHIJ]] -- no longer JSON. dotenv does not
overwrite variables already present in the environment, so the mangled
shell value beat the correct file value and every scan died before it
started:

  SyntaxError: Unexpected token 'G', "[[GCVJ4Z6TI6"... is not valid JSON

The scan only worked outside the devShell, which is the opposite of what
the devShell is for.

Removed the sourcing rather than quoting around it: Config.ts and
AppDataSource.ts both call dotenv, so exporting these into the shell was
redundant as well as destructive, and any value containing quotes was
liable to the same mangling.

Also documents this, the IPv4/localhost fetch failure, and the QBF
requirement behind "Core forks if: Unknown" in docs/local-development.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a working ipstack key produced no change: 4 geo rows for 220 nodes,
ISPs and countries still 0. Two separate bugs, both found by running it.

1. A failed geo lookup was permanent.

   NodeScannerGeoStep only asked for getModifiedIPs() -- nodes whose IP
   changed this scan. A node is geo-located once, when its IP is first
   seen. If the provider was down, the key was wrong, or the network was
   unreachable at that moment, the node stayed without a country or ISP
   until its IP happened to change, which for a stable validator is never.

   That is the root cause of the "safety threshold of 0 countries" report:
   staging's geo lookups all timed out once, and nothing ever retried them.
   Nodes missing these fields collapse into one synthetic "Unknown" group,
   and a single group cannot be split or blocked.

   getIPsNeedingGeoData() now returns changed IPs plus any node still
   carrying no geo data, and a partial failure is logged rather than
   counted silently. Result on the next scan: 222 lookups, 226 geo rows,
   countries went from 0 to 3, matching production.

2. "No splitting set found" was being stored as 0.

   python-fbas reports that when safety cannot be broken at a grouping at
   all -- for pubnet, no set of ISPs can split the network. It is the best
   possible answer and it was rendered as the worst.

   Worse than cosmetic: detectSafetyEvents treats minSplittingSetSize === 0
   as total loss of safety and notifies subscribers. A network that became
   MORE robust would have sent the alarm. Two further checks compared
   possibly-null values with <=, and `null <= threshold` is true in
   JavaScript, so they would have fired as well.

   The splitting-set sizes are now nullable end to end, null meaning "no
   such set exists". The event detector treats null as "no comparison
   possible" rather than as zero.

   Also stopped one failed sub-analysis from zeroing its whole level: the
   splitting-set call may fail on its own while the top tier and blocking
   sets for that grouping are still reported. That is why halts_if_isps
   reads 1 now instead of 0.

Final scan: orgs 4, ISPs 1, countries 3 for liveness; ISP splitting null
(no such set), country splitting 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "what does not work locally" entry for geo data was written when a
failed lookup was permanent. With a real ipstack key the next scan
backfills every node that has none, so it is a setup step rather than a
limitation. Documents the retry behaviour, warns that the first such scan
is slower, and notes that a free ipstack plan omits the `connection`
object the ISP level needs.

Promotes FBAS analysis out of the same list. Running the published image
is the supported path: `top-tier` needs QBF via pyqbf, which builds
through cmeel and a C++ toolchain and does not compile cleanly under nix,
while the image already has it. The Unknown troubleshooting entry now
points here instead of repeating it.

Adds a known-good result to compare a local scan against, and explains the
two cells most likely to be mistaken for bugs: ISPs "halts if 1" is real
concentration, and an ISP splitting set of "none exists" is the best
possible answer rather than a missing one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two safety figures were identical -- both 4 -- so the split served no
purpose. The cause was not the calculation but the input.

NetworkScanner restricts the whole FBAS analysis to the transitive network
quorum set via nodesInTransitiveNetworkQuorumSetFinder: 33 nodes, 10
organizations. That restriction is deliberate and should stay, because most
of the analysis is exponential in top tier size. But it means Radar's
"organization splitting set" only ever answered a question about the top
tier, while its label implied the whole network. python-fbas without
--reachable-from sees every validator, which is why it reported a smaller
number and started this whole thread.

The fix widens exactly one figure. analyze() now takes the full validating
node set as a third argument and recomputes the organization splitting set
over all of it, leaving everything else restricted. On pubnet this analyses
18 organizations rather than 10, and the two figures finally differ:

  network-wide (Nodes cut off by)  3
  top tier     (Core forks if)     4

Which is the distinction Giuliano described: severing an outlying validator
from the core takes fewer organizations than splitting the core itself.

The widening cannot cost a scan anything. The argument is optional and
omitting it reproduces the previous behaviour; an invalid aggregation or a
failed service call logs and keeps the restricted answer rather than
discarding the level.

One bug caught while writing this: the scanner's filter was
`node.isValidating` rather than `node.isValidating()`. A method reference
is truthy, `Array.filter` accepts any return value, and TypeScript raised
nothing -- so every node would have been passed as "validating".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ckup

Two pieces of housekeeping, one of them cleaning up after myself.

.gitignore had `venv/`, which does not match `.venv` -- the conventional
name. A `git add -A` in 0cbbd67 therefore committed the whole
python-fbas-service virtualenv: 618 files, 45MB of installed Python
packages. Added the missing rule and untracked the directory; it stays on
disk, since the parser tests run against it.

The blobs remain in this branch's local history. The branch is pushed only
as far as 34354b9, so none of it has left this machine and it can still be
stripped before the branch goes up -- see the note in the PR discussion
rather than a rewrite here, because rewriting 15 commits is not something
to do unasked.

Also deletes network-dashboard.vue.backup, which was referenced by nothing
and turned up in every grep for Bootstrap classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reachable

The three quorum-set dialogs -- Stellar core config, Add Validators, Add
organization -- were raw Bootstrap `.modal` markup opened with
`$(el).modal("show")`. Bootstrap's JavaScript is never loaded, only its
CSS, so the jQuery plugin call was a no-op and none of them could open.
All three now use UiModal, driven by the `show` prop and `close` event the
parent was already passing and the children never implemented.

Two things fell out along the way:

- Their `visible` flags existed to keep heavy tables out of the DOM until
  the dialog opened, set from `show.bs.modal` handlers. UiModal's `lazy`
  does that, so the flags are gone.

- All three were wrapped in `<portal to="quorum-set-modals">`. portal-vue
  is not installed or registered, so those were unresolved custom elements.
  UiModal teleports itself; the wrappers and the two now-orphaned
  `<portal-target>` elements are removed.

Then the specs found something the conversion could not fix: the component
that renders these dialogs, node-side-bar.vue, is referenced by nothing.
quorum-set-dropdown and quorum-set-actions hang off it, so the whole chain
is unreachable in the running app -- which is why nobody noticed the
dialogs were broken. The conversion is still worth having (it removes the
last jQuery modal usage outside the orphaned consent.vue, and the code is
correct if the sidebar is revived) but it cannot be exercised through the
UI.

So the Playwright spec covers what a user actually reaches: the node detail
page grew its own Stellar core config path during the redesign, already on
UiModal. Two tests, both passing -- the dialog opens and generates a real
config, proving the @shown hook runs rather than merely that a dialog
appeared, and closing it releases the body scroll lock. That second one
matters because a dialog stuck open leaves the page unscrollable, which is
worse than one that never opens.

Writing them required giving two icon-only buttons accessible names: the
quorum set actions kebab and the node header's UiDropdown toggle, which
took a new `ariaLabel` prop. Neither had any accessible name, so neither
screen readers nor tests could address them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine components deleted, verified unreachable rather than assumed so. A
naive import-graph walk was not trustworthy -- it flagged NodeDetailPage,
which Playwright had just driven -- because it misses router lazy imports
and globally registered components. So each file was checked individually
for references by both its kebab-case and PascalCase name, and each of
their dependencies checked for whether it became orphaned in turn.

Deleted, the whole chain hanging off an unreferenced root:

  node-side-bar.vue                        (referenced by nothing)
  quorum-set-dropdown.vue                  (only by node-side-bar)
  quorum-set-actions.vue                   (only by quorum-set-dropdown)
  quorum-set-toml-export-modal.vue         (only by quorum-set-actions)
  quorum-set-add-organizations-modal.vue   (only by quorum-set-actions)
  quorum-set-add-validators-modal.vue      (only by quorum-set-actions)
  node/sidebar/organization-validators-dropdown.vue  (only by node-side-bar)
  add-validators-table.vue                 (orphaned by the above)
  consent.vue                              (referenced by nothing)

Kept because they are still used elsewhere: node-actions, nav-link,
side-bar, quorum-slices, simulate-new-node, add-organizations-table, and
the organization sidebar's own similarly-named dropdown.

With those gone there is no jquery usage left, so jquery, popper.js,
@types/jquery and @types/bootstrap are removed. Two places had to follow:
tsconfig listed jquery and bootstrap in `types`, and vite.config declared a
manualChunks entry for jquery, which fails the build as an unresolvable
entry module once the package is gone.

bootstrap itself stays. custom.scss still compiles bootstrap/scss, and
around 60 components still use its classes -- that is D3.4.

The ~88kB jquery chunk is gone from dist.

Verified: clean build, 1143 unit tests, and the three Playwright specs
still pass against the running dev server -- including the node detail
Stellar core config dialog, which shares UiModal with the dialogs deleted
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CSS goes from 345kB to 126kB: a 218kB reduction, and the last of the
Bootstrap dependencies is gone from package.json.

The app used about 60 Bootstrap and Tabler class names out of the ~1500
those stylesheets ship. Rather than rewrite 87 components' markup in one
pass -- a change too large to verify -- those 60 are reimplemented on Radar
tokens in assets/tailwind.css under @layer components. It is a bridge:
every class there is one a component should eventually stop using in favour
of src/components/ui, but it is ~340 lines we own instead of two vendor
stylesheets we do not, and the per-file migration can now proceed
independently and be verified a file at a time.

Not reimplemented, deliberately: ms-*, me-*, lh-* are Bootstrap 5 names in
a project that was on 4.6.1. They have never applied. Templates using them
got no spacing before this change and get none after.

tabler-ui/variables stays because graph.vue reaches $blue through
assets/variables.scss for its SVG strokes. That is the last thread back to
the vendored theme.

Two regressions the automated checks did not catch, found by looking at a
screenshot:

- The search suggestions dropdown was permanently open. search.vue toggles
  `.show` on `.dropdown-menu`, so Bootstrap's `display: none` default was
  load-bearing rather than decoration.

- simulate-new-node's dialog rendered inline at the bottom of every page
  that mounts it, for the same reason on `.modal`.

Both now hide by default and show on `.show`, which preserves exactly the
previous behaviour: Bootstrap's JavaScript is not loaded, nothing ever adds
`.show` to that modal, so it stays hidden as it always has.

Adds a visual smoke spec covering the five main pages. It cannot judge
whether a page looks right -- only a person can -- but it catches what a
large CSS deletion actually breaks: script errors, pages collapsing to no
height, and sections with content but zero height. Screenshots are written
for review and gitignored.

Verified: 1143 unit tests, five pages rendering, and the dashboard
reviewed by eye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
50 stylesheet files removed. What kept the directory alive was five colour
values that two graph components and trust.scss reached for through
assets/variables.scss:

  $graph-primary  #1687b2   $white  #fff
  $gray-100       #f8f9fa   $red    #cd201f   $yellow  #f1c40f

They are SVG strokes and fills, so they cannot come from the CSS custom
properties in tailwind.css and have to stay as SCSS. Inlined at their
original values, so the graph renders exactly as before -- confirmed by
screenshot, since a wrong colour here is not something a test would notice.

Noted in the file: $graph-primary is Tabler's blue and is not part of the
Radar palette, which makes the graph the last place in the app still
painted in the old brand colour. Worth tokenising, but that is a visual
decision rather than a deletion.

custom.scss now imports ./variables instead of the theme, and its `a { }`
rule follows the same value rather than Bootstrap's $blue.

CSS output is unchanged at ~127kB: the theme was already excluded from the
bundle when its import was removed. This commit removes the dead source,
not more weight.

Verified: 1143 unit tests, seven Playwright specs, and the dashboard graph
reviewed by eye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The network graph rendered at a fraction of its container. Its wrapper is
sized entirely by `h-100`, a Bootstrap utility that D3.4 removed without
replacing, so the SVG's height="100%" resolved against an auto-height
parent and collapsed.

Twenty-five classes were lost the same way, all of them easy to miss
precisely because they look like Tailwind: `h-100` sits beside Tailwind's
`h-10`, `align-items-center` beside `items-center`, `position-relative`
beside `relative`. My audit for D3.4 classified them as Tailwind on the
prefix and never checked whether they resolved.

Restored: h-100, w-100, position-relative, flex-column, flex-fill,
align-items-{start,center,baseline}, justify-content-{start,center,between,
around}, font-weight-bold, list-unstyled, small, mt-md-2, p-lg-6,
order-lg-1, and the Tabler structural classes page, nav, dropdown, icon,
tag, input-icon and input-icon-addon.

How this was found matters more than the fix. Two of my own checks said the
change was clean and both were wrong:

- The D3.4 audit tested class names against a hand-written list of Tailwind
  prefixes rather than against Tailwind's actual output.

- The follow-up comparison built the pre-change commit to diff against, but
  bootstrap had already been removed from node_modules by an earlier
  `pnpm install`, so the SCSS import silently produced nothing and both
  sides came out equally broken. It reported zero regressions.

The check that worked compares template classes against the selectors
actually present in the built CSS, with one side built from main after a
clean install. That now reports parity: no class that worked on main is
dead on this branch.

Verified: 1143 unit tests, five pages rendering, and the graph confirmed by
eye to fill its container again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mber

All four were written for the network analysis tool and inherited by the
verdict block unchanged. They opened with "What does this analysis show?"
and described a chart, lists of sets, and a grouping control the reader
cannot see, then defined the FBAS term without ever connecting it to the
figure on screen. The liveness one worked an example with a different
number than the one displayed.

Each now leads with what the number means, says what would have to happen
for it to matter, explains why it is the value it is, and only then names
the formal concept and links the paper. The arithmetic is included because
it is the part that makes the figures actionable: with a symmetric top tier
of T of N, halting takes N - T + 1 and forking the core takes 2T - N, so a
reader can see that raising the threshold hardens safety at the cost of
liveness and that the two cannot both be maximised.

Three corrections of substance, not just tone:

- Attribution said "Powered by wiberlin/fbas_analyzer" on all four.
  Production runs python-fbas, with the Rust analyzer only as fallback.
  Both are now credited accurately, which also stops miscrediting the
  project whose author reported the discrepancy these figures came from.

- The safety explainer taught the bug we fixed this week: "If a splitting
  set of size zero is found, this means that there is no intersection
  between some quorums." No splitting set existing and a threshold of zero
  are opposite findings. It now distinguishes them and says why Radar shows
  the first as unknown rather than 0.

- Nothing acknowledged that "Core forks if" and "Nodes cut off by" are the
  same analysis at two scopes. The safety explainer now covers both, and
  says plainly that reporting the top-tier figure under a network-wide
  label was the more misleading option.

Six Playwright tests cover each button opening the right explainer, the
attribution being present, and the copy not drifting back to describing the
analysis rather than the number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 22, 2026 18:33 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 25, 2026 17:19 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 25, 2026 17:19 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 25, 2026 17:19 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 25, 2026 18:05 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 25, 2026 18:05 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 25, 2026 18:06 — with GitHub Actions Inactive
…ives as ok

The image installed the Go archivist from an unpinned @main, while the scanner
now speaks the Rust CLI: subcommand-scoped --low/--high and a JSON report via
--report. Staging showed the mismatch as every invocation returning in ~50ms
with exit 0 and no report, so no archive was verified at all. Under the older
scraping parser the same no-op looked like a clean scan that advanced the chain
by zero ledgers, which is why scan chains sat frozen at the ledger the
TypeScript scanner last reached.

Build stellar-archivist 28.0.0 from crates.io on rust:1.91-alpine instead. The
version is pinned because the scanner drives this binary as a subprocess: an
unpinned @main lets an upstream CLI change break verification with no build
failure, and kaniko caches the install layer indefinitely since the command
string never changes. The crate uses rustls rather than OpenSSL, so musl-dev is
the only build dependency, and the resulting binary runs in the node:20-alpine
runtime (verified by building the stage and running --help inside it).

VerifyArchives checked in 'ok' unconditionally after every scan, so an archive
that was never reached was indistinguishable from a healthy one. It now checks
in 'error' when the scan aborted with a CONNECTION error, while a scan that ran
and found archive defects still checks in 'ok' - it did its job. Also fixes the
adjacent log line, which passed the hasError function rather than calling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjRxs5zPEq6Q7GatFf17VQ
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 26, 2026 16:23 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 26, 2026 16:23 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 26, 2026 16:23 — with GitHub Actions Inactive
NullableSplittingSets1790200000000 made "no splitting set exists" representable
as null on network_measurement, but left the day and month aggregates NOT NULL.
The rollup aggregates those columns with min/max/sum, all of which return null
once every scan in the window reports no splitting set, so the insert failed:

  null value in column "minSplittingSetISPMin" of relation
  "network_measurement_day" violates not-null constraint

and the entire rollup was abandoned - no day or month rows for that window.
Null now means the same thing in the aggregates as at the source.

Only the grouped variants change. minSplittingSetSize stayed NOT NULL in the
earlier migration, so minSplittingSetMin/Max/Sum stay NOT NULL here; it is the
per-grouping questions ("which ISPs could split the network") that can have no
answer.

Known limitation, left for the authors of the earlier migration to settle: the
rollup accumulates sums with plain addition, and NULL + n is NULL, so one
all-null batch permanently nulls that day's sum even when earlier batches had
values. LEAST/GREATEST ignore nulls, so the min and max columns are unaffected.
Coalescing to 0 would fix it by reintroducing zero-as-data, which is the bug
1790200000000 set out to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjRxs5zPEq6Q7GatFf17VQ
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 26, 2026 16:37 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 26, 2026 16:37 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 26, 2026 16:38 — with GitHub Actions Inactive
The verifier read only stderr, but stellar-archivist writes its tracing output
to stdout - including the progress lines. Only clap argument errors go to
stderr, so every liveness signal the tool produced was discarded, and the
five-minute heartbeat reported "still running" whether the scan was advancing
or wedged.

Staging has been sitting on ledgers 1,000,000-2,000,000 of one archive for 69
hours. The same range against the same archive finishes in 3m18s elsewhere, so
the process is stuck rather than slow - but the logs could not tell the two
apart, and the heartbeat read as reassurance the whole time.

Stdout is now parsed for:

  Processing 15627 checkpoints from 999999 (0x000f423f) to 2000063
  Progress: 100/15627 checkpoints processed

and the heartbeat carries checkpointsProcessed, checkpointsTotal, the delta
since the previous heartbeat, and a stalled flag set when the count has not
moved between them. The total is announced before the first progress line, so a
scan that wedges early still reports how much work it was given instead of
nulls. The completion log carries the final counts too.

parseArchivistProgress is exported and covered directly; its tests live in the
verifier suite because importing the verifier elsewhere pulls in inversify's
decorators without reflect-metadata.

This does not stop a wedged subprocess from blocking the queue - VerifyArchives
is sequential, so one hung scan halts every archive behind it. It makes the
hang visible; bounding it is still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjRxs5zPEq6Q7GatFf17VQ
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 29, 2026 14:48 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 29, 2026 14:48 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 29, 2026 14:48 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 30, 2026 13:30 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII deployed to obsrvr-radar-production September 30, 2026 13:30 — with GitHub Actions Active
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging September 30, 2026 13:30 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII merged commit db03540 into main Oct 1, 2026
12 checks passed
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging October 1, 2026 07:15 — with GitHub Actions Inactive
@tmosleyIII
tmosleyIII deleted the feature/tmosley/network-verdict branch October 1, 2026 07:15
@tmosleyIII
tmosleyIII temporarily deployed to obsrvr-radar-staging October 1, 2026 07:15 — with GitHub Actions Inactive

This branch was successfully deployed

1 active and 1 inactive deployments
obsrvr-radar-staging — 5eab7461 Deployed Oct 1, 2026 by tmosleyIII via Destroy Staging Environment #7
obsrvr-radar-production — 5eab7461 Deployed Sep 30, 2026 by tmosleyIII via Plan Production Environment #284
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.

1 participant