Repository navigation
Quarter 3 D2 and D3 deliverables - #65
Merged
Merged
Conversation
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
deployed
to
obsrvr-radar-production
September 21, 2026 13:44 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 21, 2026 13:44 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 21, 2026 13:44 — with
GitHub Actions
Inactive
…"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
deployed
to
obsrvr-radar-production
September 22, 2026 18:33 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 25, 2026 17:19 — with
GitHub Actions
Inactive
tmosleyIII
deployed
to
obsrvr-radar-production
September 25, 2026 17:19 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 25, 2026 17:19 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 25, 2026 18:05 — with
GitHub Actions
Inactive
tmosleyIII
deployed
to
obsrvr-radar-production
September 25, 2026 18:05 — with
GitHub Actions
Active
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
deployed
to
obsrvr-radar-production
September 26, 2026 16:23 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 26, 2026 16:23 — with
GitHub Actions
Inactive
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
deployed
to
obsrvr-radar-production
September 26, 2026 16:37 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 26, 2026 16:37 — with
GitHub Actions
Inactive
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
deployed
to
obsrvr-radar-production
September 29, 2026 14:48 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 29, 2026 14:48 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 29, 2026 14:48 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 30, 2026 13:30 — with
GitHub Actions
Inactive
tmosleyIII
deployed
to
obsrvr-radar-production
September 30, 2026 13:30 — with
GitHub Actions
Active
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
September 30, 2026 13:30 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
October 1, 2026 07:15 — with
GitHub Actions
Inactive
tmosleyIII
temporarily deployed
to
obsrvr-radar-staging
October 1, 2026 07:15 — with
GitHub Actions
Inactive
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finish work around UI cleanup and FBAS scanner