Skip to content

feat(dos): a residential node holds, evacuates, and only then goes to DOS - #1784

Merged
Cabecinha84 merged 122 commits into
developmentfrom
feat/dos-residential-non-arcane
Sep 3, 2026
Merged

feat(dos): a residential node holds, evacuates, and only then goes to DOS#1784
Cabecinha84 merged 122 commits into
developmentfrom
feat/dos-residential-non-arcane

Conversation

@Cabecinha84

@Cabecinha84 Cabecinha84 commented Aug 13, 2026

Copy link
Copy Markdown
Member

A node on a residential connection is only fit to serve the network when it runs ArcaneOS. This puts such a node out of service in three stages, so that the destructive step is only ever reached on a node holding nothing.

HOLD      stops accepting NEW apps. Immediate, and it deletes nothing.
EVACUATE  gives up one app at a time, and only ones another host demonstrably holds.
DOS       once it runs nothing, and removeAllAppsLocally finds nothing to delete.

DOS >= 100 is not a mark: nodeStatusMonitor calls removeAllAppsLocally on it and appStartupManager does the same on every boot, so a node in that state has every app directory and volume on it deleted. Reaching it only on an empty node is the whole point of the staging.

There is no in-FluxOS off switch, and the one that exists is better. Enforcement acts only on a RESIDENTIAL verdict from the published table, so blanking orgClasses in fluxos-network-policy ends it fleet-wide within a day — no release, no node upgrades, nothing to roll back. A config flag would have needed all three.

The trust boundary this inherits, stated rather than inherited silently. The table is fetched unsigned from fluxos-network-policy; trust is TLS, GitHub, and write access to that repo, and this PR moves what those bytes can do from "placement quality" to "stage a non-ArcaneOS residential node out of service". That is a conscious trade. It is bounded — at most the 541 non-Arcane slots with readable bench, one app per 6 hours behind the full safety gate, DOS only on an empty node, self-healing on the corrected row — and it replaces a weaker root, not a stronger one: today's classification rests on plaintext http://ip-api.com, forgeable per node by any on-path party with no history to audit, while every byte the policy repo has ever served is in its git history behind branch protection (no force-push, admins bound, validated on publish — though merges do not yet require a second reviewer). Signed manifests (the fluxhashes scheme, in progress) are the successor; this artifact adopts that signing when it lands.

What it acts on

Measured against stats.runonflux.io/fluxinfo (6,115 slots on 2,513 machines, 5,822 slots with readable bench) and api.runonflux.io/apps/locations (6,340 placements, 1,085 apps), classified by reading the published artifact through this branch's own ipLocationStore and networkClassifier.

Enforcement acts only on a RESIDENTIAL verdict from the published table. Of the 541 slots on 316 hosts whose bench confirms they are not ArcaneOS:

verdict hosts slots share app placements
Residential — enforced 83 218 40% 494
Datacenter 195 253 47% 52
No published verdict — never enforced 38 70 13% 98
Apps losing every copy 0
Volumes deleted none

A legacy node is very nearly as likely to be in a data centre as on a home line, so "not ArcaneOS" is not a usable proxy for "residential" and is not used as one. A further 293 slots, on 83 hosts, have no readable bench at all; isArcaneOs() returns null for those and a null tick decides nothing.

No node in this population vetoes its published verdict: the node veto declines a published RESIDENTIAL on local hosting evidence, and the one fleet host it currently covers — 213.44.137.57 — runs ArcaneOS, so it is outside the enforceable set to begin with.

Why it is staged rather than immediate

63 apps have every copy on a node that qualifies for enforcement:

apps stateful
Single instance 49 0
2+ instances 14 9

None of the 49 single-instance apps hold synced data. An owner who deploys instances: 1 chose a service with no continuity guarantee, and the platform's standing semantics for any departing node — expiry, powercut, surplus trim, geolocation redeploy — already remove the instance and its volume; nothing anywhere preserves an unsynced volume, so the rebuild starting empty is not a property of this path. Evacuation is in fact the gentlest departure the platform has: the removal is broadcast immediately and the spawner rebuilds elsewhere at once, where a node that simply dies leaves its stale location record holding the app at apparent full strength for up to 125 minutes (the running-location TTL) before anyone acts. It is still an outage window, and a longer one where placement is constrained by a geo pin, hardware, or enterprise ownership.

All 9 stateful ones are g: apps at exactly 2 instances: seven Palworld and two Minecraft world saves. The ordering is what makes them safe — the junior leaves, the senior copy still holds the world, the replacement syncs from it, and only then does the senior go.

218 slots sit on 83 machines. Several FluxNode registrations routinely share one physical connection — a residential line hosting 8 nodes loses all 8 at once. The spawner already refuses to place a second instance of an app on an occupied IP, so one app's instances land one per host by construction; the safety gate's other-host count dedupes to machines as well, covering the pair that converges after placement through an IP change.

Why the evacuation terminates

The departure creates the deficit that drives it. Only 4 apps in the entire fleet carry any surplus — the trim path removes above N and the spawner fills below N, so the network sits at exactly-N and a drain conditioned on staying above the requirement never starts.

So the node leaves first: the app is one short, the spawner is already asking every 120s who is short, a node that is not held volunteers, and the count returning to N is what releases the next holder. Serialisation needs no coordination and no wire change — a departure leaves the app short, every other holder's gate refuses to act on a short app and resets that app's queue clock, so each further departure waits for the spawner to restore full strength and then serves its full wait again.

The queue ticket is an uninterrupted observation, not elapsed time, and that is what keeps position binding on every departure rather than only the first. A node inside its own departure interval cannot act on anything, so it records nothing against its tickets; the block then reads afterwards as one long gap and every ticket starts again. Without that the tickets matured untouched through the six hours, the node came out of the block instantly ready for everything it held, and two holders whose blocks expired on the same pass — the pass is keyed on block height, so the fleet evaluates together — could hand back the same app in the same pass, each reading it at full strength because the removal is broadcast after the volume is already gone. The strength test sits at the same point: an app short of its instance count accrues nothing towards its turn, so the wait means what it says. Both clocks are monotonic, so a step of the system clock cannot mature a ticket against time the node never spent watching.

Position in the shared instance order sets a delay, never a veto. "Only the most junior may leave" deadlocks, because the replacement is itself the most junior and does not want to leave.

The junior copy leaves first, which is the same order reasonToGiveUpApp already ranks SURPLUS by and for the same reason: the newest copy stands aside and the senior one goes on holding the data. It also puts the one case that cannot simply leave at the back of the queue rather than the front — the elected primary is the senior instance, and it has to stand down and hand the app back before it can go.

The pacing is config-tunable: 6h between evaluations, a 24h settling window before anything moves customer data, 6h between departures, and a queue ticket of 30min + position × 40min. The step has to outlast the pass that reads it — mayEvacuateApp is reachable only from the give-up pass, which runs every removeFluxAppsPeriod (11) × speedMultiplier (4 post-PON) = 44 blocks = 22 minutes, so maturity is quantised to a 22-minute grid and a shorter step cannot separate adjacent positions on it. That inequality is asserted against production's own config in the unit tests. Two intervals are hardcoded rather than tunable: RETRY_INTERVAL_MS (5min, the retry after a tick that could not decide — it doubles up to and is capped by the check interval) and staticIpStabilityDays (10).

What the grid does not separate. The step separates positions on the 22-minute grid; it does not separate maturities that began at different moments. A ticket is measured from each node's own observation start, so two holders whose starts differ by about a step mature inside the same pass whatever the step is — and the pass is block-keyed, so the fleet evaluates together. What keeps that empty is not the pacing: every stateful 2-instance app on the network is g:, and the election refuses to let the writer leave, so one of any two colliding holders declines regardless of its turn. That is a property of today's app population, not a guarantee this code makes. Closing it structurally means taking the turn from the shared clock the pass already runs on — a node acts only in the pass its rank selects — rather than from elapsed observation, which is a private measurement answering a fleet-wide question. Per-node jitter does not close it: it separates a colliding pair by the difference of two fixed offsets, which can be zero, and moves an already-separated pair together as readily as apart.

The settling window counts time the node observed the verdict, not time that elapsed. Each confirming tick credits the gap since the last, capped at one check interval, and credits nothing once the gap is long enough that the node plainly stopped watching. It is persisted in the nodeStartupTracker collection, so restarting FluxOS costs nothing and cannot postpone the drain — and a node that answers once, says nothing for a day, then answers once more has accrued two ticks, not a day.

An empty residential node is DOSed on the first decisive tick, with no settling window at all. The window exists to protect customer data and an empty node has none; the DOS deletes nothing and is released the moment the node is attested again. It does mean a mistaken policy row reaches empty nodes in minutes rather than a day.

The classification

A node is RESIDENTIAL only when something positively says so and nothing contradicts it:

RESIDENTIAL a positive signal, no contradiction
DATACENTER positive hosting evidence, no positive residential signal
CONFLICTED both — never enforced
UNKNOWN neither — never enforced

The published location table is the authority, and where it holds no verdict nobody does. It is built in fluxos-network-policy from registry evidence a node cannot gather for itself: six thousand nodes cannot each query the RIRs. There is no fallback to the node's own reading — that rule is the published rule with its strongest signal removed, its error rate has never been measured, and it would have decided for the 13% of the enforceable population — 70 of the 541 non-ArcaneOS slots — whose organisation carries no published verdict, on the one path that deletes customer data. Tuning belongs in the policy repo, where a verdict is evidence-backed, auditable, and correctable by hand through data/orgclass-overrides.json without a FluxOS release.

What the node's own evidence still does is decline a published RESIDENTIAL about its own address. An organisation is decided by 80% of its hosts agreeing, so a minority tail of the other kind is guaranteed by construction, and the veto is how one of them steps out until someone adjudicates the range. It can only ever remove a node from enforcement.

The evidence a verdict is read against can lag an address change by up to three days. getNetworkClassification() looks the published table up on networkEvidence.ip, which is refreshed on setNodeGeolocation's three-day cadence, while the enforcement tick runs every six hours — so a node whose address moved between geolocation passes is classified against its previous address's row until the next one. It cannot begin a drain inside that window: both the 24h observed window and the queue ticket restart on the change. The HOLD is immediate, which is the conservative direction. isDataCenter() and isStaticIP() carried the same latency before this PR.

Measured at the point of enforcement: of the 1,569 fleet hosts ip-api positively calls hosting, across all 29 hosting ASNs the fleet uses, exactly one carries a published residential verdict — 213.44.137.57, in Bouygues' consumer space — and the node veto covers it. One ledger-level error in 1,569, zero enforced.

That is the only accuracy figure worth quoting for this design. A rate measured on the classifier alone cannot be one: hosting is itself a contradiction, so RESIDENTIAL is unreachable on that population whatever the other signals say.

Signals are read from the operator (isp/as), never from org, which is the block registrant and frequently a reseller: 46.250.240.89 carries isp "Contabo Asia Private Limited" and org "Yorkshire Tech Limited". Link asymmetry corroborates and never decides — a bench figure is a speed test's result, not a property of the link, so it can support a verdict the real signals already reached and can never reach one.

A verdict also requires that the contradicting signals were consulted at all. When ip-api answers 200 with an unusable body, geolocationService falls back to stats.runonflux.io, which carries none of hosting/proxy/mobile/isp/asfluxstats never requests them and its /fluxlocation handler projects them away. On that path an empty evidenceAgainst means nobody looked rather than nothing found, and the node is left unclassified rather than residential.

No table means no verdict — not a fallback. A booting node fetches a 4.6 MB artifact and ingests two million rows while a single ip-api call answers in milliseconds, so the table is reliably absent at the moment a booting node would otherwise decide, and nothing enforces on null.

The verdict is not stored. setNodeGeolocation gathers evidence on its three-day cadence; getNetworkClassification() reaches the verdict when asked, from that evidence and whatever table the node currently holds — which is what placementFeasibility, hwRequirements and appSpawner already do with the same table.

What staticip is for, and why the rule changed

staticip is two promises, and the apps that ask for it need both. The live consumers are VPN endpoints (cumulusvpn*), Kaspa nodes, and quipbootnode — peers and clients hold an ip:port pair written down, so what they need is an endpoint that stays reachable at a fixed address:

  1. Directly connected — the node holds the address itself, so reaching it does not depend on a port mapping.
  2. Not seen to move — this node has not watched that address change.

Both are properties of the host, so only the host can attest to them. The whole rule is now four rows of observation, and no table, vendor flag or operator name appears in it:

this node watched it change <10d ago public address on the interface verdict
yes UNKNOWN
no yes STATIC
no no (NAT) DYNAMIC
no unreadable UNKNOWN

Only STATIC satisfies the requirement. UNKNOWN and DYNAMIC both fail it and are kept apart, so a node that could not answer does not read as one that answered "behind NAT".

What the fleet actually looks like

Random sample of 500 ArcaneOS slots, 442 reachable, reproducing hasPublicIpOnInterface() on each node by SSH. Scaled ×12.3 to the 5,438 reachable Arcane slots.

how the node reaches the world slots share claim staticIp today fleet est
Public address on its own interface 102 23.1% 102 (100%) ~1,255
Behind a UPnP router 336 76.0% 126 (38%) ~4,134
NAT, no UPnP (1:1 or manual forward) 4 0.9% 0 (0%) ~49

95% CI on the public-address share: 19.1–27.0%.

Of the 228 slots advertising staticIp: true, 126 — 55% — are behind a UPnP router on an RFC1918 address. Every one of them qualifies through a third-party flag about the address RANGE, never through anything the node observed about itself:

route to static slots
ip-api hosting: true 112
ip-api proxy: true 12
no ip-api record at all 2

That is storedGeolocation.static = proxy || hosting on development. So "static IP" has meant "ip-api says this address sits in a hosting or proxy range" — a statement about the block, not about the node.

Why a range flag answers neither promise

  • It cannot see whether the host is directly connected. Those 126 slots reach the world through a UPnP port mapping, which no vendor flag knows anything about. The address can be rock solid while the mapping lapses on a router reboot, a lease expiry, or a firmware quirk — the fleet UPnP survey found 135 router models and defects in several. The node cannot see its own public address, cannot verify it, and the claim is unfalsifiable locally.
  • It does not imply a fixed address either. "This block is assigned to a hosting company" states what the block is for. An operator reassigns on rebuild, migration, or a released elastic address, and the node comes back on a different address — same hosting range, same DATACENTER verdict, different endpoint.

The 12 qualifying on proxy are the clearest failure: that flag is a VPN artefact, and geolocationService's own comment said so while the code it commented on granted static on exactly that basis.

What it costs

Arcane slots that stop claiming static ~1,550 (95% CI 1,321–1,779)
Static pool 2,800 → ~1,255
Slots that gain static 0 — all 102 public-address slots already claim it
Apps requiring staticip 44
Placements currently on slots that would lose it 74

Nothing evicts those 74. checkAppStaticIpRequirements is reachable only from checkAppRequirements, which runs on install and redeploy; there is no periodic compliance sweep in ZelBack. They drain by attrition as owners push spec updates, because reinstallOldApplications removes before it checks.

How much of that attrition touches data, measured: of the apps requiring staticip, 3 carry synced components — all of them r:, all at instances: 3, 9 placements between them. Two of those 9 sit on slots that lose static. Every other app requiring staticip keeps no synced state at all, so an attrition removal costs them a container the spawner rebuilds from the specification.

So the bound is one of three copies, on two apps, re-synced from the two survivors; losing an app outright would need all three of its copies on losing slots and an owner update, which no app in the population is in a position for. The removal is still a removal that the evacuation safety gate never sees, and that is the honest residual here.

The ordering behind it is not this branch's to fix, and is being fixed. reinstallOldApplications deletes the app and its volume and only then asks whether the node may still run it, so any requirement that fails — disk, geolocation, static IP — fails after the data is gone. It fires today, on development, for every one of those reasons; this branch widens the set of nodes one of them says no to. The fix is a make-before-break drain: a node that can no longer satisfy a spec keeps serving the old one and keeps its data while being excluded from the counts that decide whether the network needs another instance, and only tears down once a replacement is ready. That is designed and lands in a subsequent PR. Reordering the check alone is not the fix — it converts the deletion into a node stuck on a stale spec forever, which is why it is not done here.

The operator's remedy is to put a routed public address on the interface rather than NAT, which is the behaviour worth steering toward.

What else moves with it

isStaticIP() is not read only by the staticip gate. It also drives a spawner deferral: a node claiming static defers apps that do not require static, so as to stay available for apps that do. So on upgrade day roughly 1,550 slots stop deferring and become immediately available for ordinary placements. That is a fleet-wide placement shift and it is not priced in the table above. It is a correction rather than a regression — those slots were deferring on a claim they could not support — and the static pool that remains, ~1,255 slots, still exceeds the 74 placements that ask for it by more than an order of magnitude.

Second-order, and ongoing rather than one-off: a genuinely static node that changes its address now reads UNKNOWN for the full ten-day stability window, where on development a hosting-range node recovered static on its very next pass through proxy || hosting. checkAppStaticIpRequirements is a hard gate throughout that window. This is the same pre-existing ordering described above, reached by a different route, and it closes with the same change.

The case this knowingly refuses

Genuine 1:1 NAT, where a fixed address is mapped to a host that never sees it locally. The sample holds 4 such slots (~49 fleet-wide) and none claims a static address today; the fleet's entire hyperscaler population is one Oracle slot. Real, and priced at nil.

The sample was drawn at random and each slot was read directly, so the shares above carry the stated confidence interval rather than being a count of whoever answered.

Behaviour this changes for every node

  • Surplus removal refuses when no connected peer demonstrably holds a stateful app's data. SURPLUS and EVACUATION share one safety gate, so this applies fleet-wide, not only to residential nodes. A surplus copy may now persist while syncthing cannot prove a peer holds the folder — the trade against deleting a last remaining copy, and the only signal is a warning every 12 refusals. The gate also refuses to hand back a g: component this node is the elected primary for, and refuses when no FDM can name the primary at all: an election that has not run is not an election that said no.

  • A surplus copy that is running the g: writer is left alone, and the next copy trims instead. "The newest copy stands aside" is a stand-in for "the least valuable copy stands aside", and when the newest copy is the one writing it is backwards — that is the most valuable copy on the network. The election is allowed to seat the writer anywhere in the instance order: it will not start a node whose data has not finished syncing, so it works down the list until it finds one that is ready, and the designated-leader branch leaves the order outright. The second-newest steps in only on a positive confirmation that the newest is running the writer, asked through the same peer probe the election uses. Silence, a timeout, a refusal and "not running" all mean it does nothing — every node ranks the same shared order, but "who is writing" is each node's own reading and FDM's registration lags it by ~110s, so the rule can only ever fail towards no trim, never towards two. What it does not do is stop the writer to make the ordering come true: an app that is over-served is not down, and an app whose writer was stopped to tidy up a count is.

  • /flux/info reports which stage of being staged out a node is indosStaging, one of null, HOLD or EVACUATE, beside the dos it leads to. Always present, so it can be counted across the fleet. The first stage was otherwise invisible: a held node takes no new apps and is in every other respect indistinguishable from one that has simply not been given work, and that stage lasts a whole settling window — the only part of this where an operator can still put the node right and lose nothing. It is derived from the settling window rather than from the evacuation flag, because that flag is a per-tick permission for the give-up pass that any undecidable tick turns off, and reporting it as a stage would show a node moving backwards through a staging it never moved backwards through.

  • During a total FDM outage, no node elects a g: primary. getMasterIpFromFdm now reports whether any region actually answered, which makes the if (!fdmOk) guard in masterSlaveApps live: a node that gets no answer from all three regions skips primary selection for that cycle instead of reading silence as "no primary set" and promoting itself. A g: app whose primary dies while every FDM is unreachable stays down until one answers. That is the trade, and it is the right one: the node the old behaviour promoted was one that could not reach three services on three continents — at least as likely to be network-isolated itself as witnessing a global outage, which is exactly the split-brain writer the election exists to prevent.

  • One instance of an app per host, and the safety gate counts hosts. The spawner refuses to place an instance on an IP that already holds one, and an app's fixed ports make a second copy on one machine moot regardless — so a given app's instances land one per host by construction. The safety gate's "does anyone else hold this?" dedupes locations to distinct machines anyway, covering the one way a pair can converge after placement: an IP change. The strength count and the SURPLUS test count registrations, which under the placement rule is the same number.

  • An app is no longer written off for twelve hours on a count that included a claim. trySpawningGlobalApplication counts instances twice — once while choosing an app, and again after it has selected and cached it — and the second count returned without clearing the entry the selection had just set. That entry is the candidate filter rather than a re-decision skip, so it removes the app from consideration entirely, and it lives twelve hours. A claim is provisional by design: the fault-domain share is checked after the claim goes out, so a claim is withdrawn seconds later once its node finds the share filled. A node that re-counted inside that window recorded "the network has this covered" for half a day. The entry is now kept when running copies alone meet the count and cleared only when the claims were needed to reach it. Clearing it unconditionally is the other way to be wrong: an app genuinely covered by running copies would re-enter the candidate pool every pass, be declined again, and never be cached — because this node never installs it, and installing is the only other thing that sets the entry. No deferral is added; appsToBeCheckedLater carries conditions lasting 30–57 minutes and this one lasts seconds.

  • A placement hold is released by its owner, and only by its owner. The hold was a single slot, so a second set overwrote the first reason outright and either caller's release cleared the other's. It is now a map of owner to reason, the owner drawn from a frozen enum, an unknown owner refused at the set, and getPlacementHold() reporting every reason held. An ownership check on the single slot would have deadlocked rather than fixed it: two owners cannot coexist in one, so the first would have been left unable to release and the node held indefinitely.

  • One removal per pass, rather than several spaced 300s apart inside one pass. The sleep held the whole pass open — every app behind the removed one waited on it, and every decision after it was made against an installed-app list read minutes earlier — and the pass is fired unawaited from the block handler, so one long enough to outlive its own interval could overlap the next. Surplus removal is correspondingly slower: one app per pass rather than several, on work that is not urgent because a surplus app is over-served rather than down.

  • isStaticIP() is observed, not inferred, and is three-state. The table above is the whole rule. publishedHosting and the staticIpOrgs list of nine hoster names are deleted; the decision collapses from six rows to four, and nothing outside the node's own interface can confer a static address. isStaticIP() is a hard gate in hwRequirements, so apps requiring a stable endpoint are no longer placed on nodes that only look stable from the outside.

  • isDataCenter() rests on positive hosting evidence. It used to be ip-api's hosting flag OR the block registrant's name matching one of nine hosters. It is now a DATACENTER verdict from the classifier: a hosting PTR, the hosting flag, or an operator that sells hosting. proxy deliberately does not qualify — it is a VPN artefact, and a home machine behind a VPN exit carries that signature and nothing else, which is exactly what an owner buying datacenter: true is paying to avoid. Measured over 2,432 fleet hosts with an ip-api record:

    hosts qualifying
    development 1,570
    this branch 1,673 (+105, −2)

    The two that lose it are 212.83.170.245 (Scaleway) and 213.44.137.57 (Bouygues), both hosting addresses whose reverse DNS carries access-network vocabulary, so the classifier reads them as CONFLICTED rather than guessing.

Also in this branch

config.fluxapps.explorerPollIntervalMs is configurable, defaulting to the current 5000. It is not how often the chain is asked: pollForNewBlocks reads a height cached by daemonServiceMiscRpcs and refreshed on its own daemonInfoIntervalMs timer, so the poll is the rate at which a node works through blocks once it knows it is behind. Its share of that refresh window — 5000/30000, 16.7% — is what decides whether a block is still the tip when processed, and everything downstream of block processing inherits it. Production is unchanged; the harness runs 833ms against a 5s refresh, which is the same 16.7%.

isSynced is documented as what it is: true when the block was still the chain tip when fetched, which is not the same as "the node is caught up". The tip comes from a cache refreshed every daemonInfoIntervalMs, and processBlock chains straight into the next block when behind, so a burst arriving between two refreshes is processed back to back and only the last block qualifies — silently skipping expiring app records, trimming surplus instances and reinstalling outdated apps for the rest. Post-PON that is a 30s block against a 30s refresh. It is named rather than worked around: v9 removes the race by pushing hashblockheight to chainTipSource.

ipLocationSync.startSync() is split. The restore half — one marker read against rows already in mongo — runs with the database schema prep in serviceManager; the fetch half, a 4.2 MB download and a two-million-row ingest, stays behind waitForDbReady. The residential verdict was otherwise waiting minutes for data it could have in milliseconds. Splitting rather than moving wholesale is deliberate: on a node with no cache, moving the fetch early would run that ingest concurrently with the app-database rebuild, which is the busiest the database ever is.

config.fluxapps.sigtermExpiryS replaces a hardcoded 420s constant, defaulting to 420 so production is unchanged. It must stay below locationTtlS: appStartupManager expires locations on (cleanShutdown && downtime > sigterm) || downtime > running, so a sigterm window above the running expiry is unreachable and a clean shutdown gets no grace at all.

fluxNetworkHelper publishes dos:changed from the sticky DOS setters, carrying the value isNodeDos() reads. residentialNodeDosService publishes residential:decided on every terminal path of a tick, with enforce three-state so a tick that could not decide is distinguishable from one that decided this node is fit to serve. Both are inert on a real node — fluxEventBus.publish returns immediately unless config.testEventStream is set.

Harness

Three shared.js keys — locationTtlS, installingTtlS, installErrorTtlS — were wired to collection-level TTL indexes dropped when expiry moved per-document, so the harness ran on production durations while the file claimed 25×, 15× and 12× compression. They are live, and HARNESS_TUNABLES.md records which are compressible and why the other two are not.

explorerPollIntervalMs is 833ms, holding production's 16.7% share of the tip-refresh window rather than the 250ms that was three times more forgiving than production.

test-infra/runner/framework/coupled-knobs.js derives the knobs that only mean something relative to another knob, and test-env.js asserts them on the effective config of every node of every fleet before boot. A ratio at or above production's passes; below throws, because that is where the property inverts rather than merely slows. Two pairs are checked: residentialQueueStepMs against the give-up pass, and sigtermExpiryS against both locationTtlS and the measured cost of one node boot.

The external-http-stub no longer synthesises static and dataCenter for /fluxlocation. The real service has never carried either — it batch-queries ip-api for location and org only — so the stub made the degraded fallback look richer in the harness than it is on any real node, and put the one path where the classifier loses its contradiction signals beyond anything a suite could observe.

Operator evasion

ZelBack/config/default.js cannot be edited to disable this: fluxbench's GetFluxCheckSum() hashes every file under the backend folder against hashes.runonflux.io, so a changed config fails the checksum, signfluxnodetransaction refuses, and the node expires at 640 blocks.

The node veto is the cheapest evasion available, and it fails open by design. getNetworkClassification declines a published RESIDENTIAL on any evidence against it, and proxy is such evidence — so an operator who routes a residential node through a commercial VPN reads as CONFLICTED, or more often as DATACENTER outright once the exit address is in a hosting range, and is never enforced against. It is not a safety bug: everything here is built to enforce only on a node that is provably residential, and every unavailable or contradictory input already means "do nothing". It is stated rather than closed because closing it would mean enforcing on contested evidence, which is the trade this design refuses everywhere else. It is not free to the operator either — a commercial VPN exit generally cannot offer the inbound reachability a FluxNode needs, and the added path shows up in the benchmark figures that set the node's tier.

Mongo is not hashed, and geolocationService restores dataCenter from the local collection when memory is empty — which is the state at boot. That is pre-existing and is not closable while an unverified machine judges itself. The settling clock is persisted precisely because restarting a service on a cron is near-accidental and worth closing; forging a classification in the database is deliberate tampering and is not.

Depends on

This PR is the top of a stack and targets #1795, not development. Its diff shows everything below it until those land:

development <- #1781 <- #1782 <- #1791 <- #1795 <- #1784

It sits on #1795 because it needs the harness port allocator that lives in buildSeedableApp — a specification is signed and hashed the moment it is built, so a port added afterwards leaves the app carrying the hash of a specification that no longer exists — and because suite 98 covers a appSpawner refusal that lives on that branch. This branch previously carried six of those commits as cherry-picks; they are gone, and the originals are underneath.

fluxos-network-policy #17, #18 and #19, all merged — the artifact carrying the organisation classes, the hand-adjudication file data/orgclass-overrides.json for the populations a supermajority vote cannot reach, and the rename of class code 2 from hosting to datacenter (a ledger-text change; the artifact carries codes, so there is no wire change). It is inert until this ships: the ipLocation stack is not on master, so no released node reads orgClasses.

Verified end to end: the artifact fetched from the live raw.githubusercontent.com/.../main URL nodes read, parsed with this branch's ipLocationStore — 2,009,526 rows, and all 1,599 ledger ranges resolve to exactly the class the ledger claims. Every fleet figure above was produced through that same reader.

Per-host lists — every host counted residential, and every host with no evidence either way — can be reproduced from the published artifact with the reader in this branch, and are available to reviewers on request.

The spawner reports its verdict when the verdict changes

spawner:noCandidates was published on every pass that placed nothing — roughly every
240ms per node, and continuously true for any fleet whose apps all sit at their instance
count. fluxEventBus states the rule at the top of its own file: facts as events, cadence
as a counter. Publishing the cadence pushed the Last-Event-ID replay window down from
most of a run to about four minutes, so nothing could afford to subscribe — leaving product
code publishing an event no listener could receive.

The cadence is a counter now, and the fact it was standing in for is an event:
spawner:candidacy, published only when this node's verdict on an app changes, and
carrying the app and the filter that removed it, or candidate. The survivor breakdown
stays in the log line, which is where it was already the useful thing.

Two unowned-lifetime fixes that came along

Neither is residential DOS. Both were found while gating this branch and both are small, so they
are here rather than trailing behind it.

checkLoggedUser answered a failure where a privilege belongs. PRIVILEGE_RESPONSE is a
published contract — the frontend branches on those four strings and reads them out of the body
whether the status says success or error. The not-authorised path put 'none' in data.message;
the catch put the thrown error's text there. fluxos-frontend's router guard clears
localStorage.zelidauth on exactly 'none', so a genuine backend failure set the privilege to a
string like 'No user Flux ID specificed' and left the stale session in place, repeating on every
navigation. Access still failed closed — no route's privilege list contains an error string — but
the user was never logged out. message now carries the contract on both paths, with the error
kept in name/code and in the log. Every other consumer of /id/checkprivilege was checked and
none reads message on the error path.

The geolocation refresh loop was one chain per caller. setNodeGeolocation reschedules itself
three ways and kept no handle, and it has two callers: serviceManager at boot and
fluxNetworkHelper on every IP change. So each IP change started another self-perpetuating loop
and none could be stopped — a node that changed IP three times ran four of them for the life of
the process, each hitting the geolocation API and writing the same record, and on a node that never
detects its IP each one logged an error every ten seconds. It is now a singleton the module owns:
one handle cancelled before it is re-armed, one pass at a time so an IP change arriving mid-pass
cannot interleave its writes, and an exported stopNodeGeolocation. This branch reads
isStaticIP, isDataCenter and getNetworkClassification from that service, which is how it
surfaced.

Testing

  • Unit: 6,057 passing, 18 pending, 0 failing, at this head.
  • Harness suite 55 (55-residential-node-evacuation), 18 tests across three live 5-node fleets:
classifies the node RESIDENTIAL from the operator, not the registrant org; leaves an
attested data-centre node alone; takes the published verdict where the node has nothing
of its own to go on; declines a published verdict its own address contradicts; leaves a
residential node alone while it is still attested; stops taking new apps the moment
attestation is withdrawn, deleting nothing; hands the app back once the settling window
has passed; the app survives the departure on every other host; goes into DOS only once
it holds nothing; releases the node the moment it becomes attested; will not hand back a
stateful app while no peer demonstrably holds its data; hands the stateful app back once
a peer holds it in full; keeps the settling clock across a restart, so restarting cannot
postpone the drain; only one residential node gives up an app at a time; stands down as
the elected primary rather than handing the app back from under itself; hands the app
back on the pass after standing down, and the app survives elsewhere; will not hand back
a g: app it is running while no FDM can name the primary; serves a fresh queue turn
after a departure, not one that matured during it

The suite runs on three fleets rather than one because every test it contains seeds an
app asking for five instances on a five-node fleet, and nothing is cleaned up between
tests. Holdings accumulate, and evacuation sheds one app per departure interval, so a
test late in the file starts against a node holding seven apps and runs out of budget
before it reaches the assertion — with the node enforcing correctly the whole time. The
stand-down tests and the one-holder-at-a-time tests each get a fleet of their own, so
what they assert is decided by the rule rather than by how many apps the node happened
to have collected. Sizing a timeout to the accumulation instead would have left the
accumulation in place for the next test written.

  • Harness suite 96 (96-surplus-trim-and-the-writer), on its own fleet with a stub peer
    holding a fourth copy of a three-instance g: app, so the surplus is built rather than
    waited for:
trims nothing while it cannot confirm the newest copy is the writer; leaves the writer in
place and trims the next copy instead

The first holds the second-newest copy where it cannot reach the newest, while the writer
goes on running: a rule that trimmed on silence would remove a copy with the writer up and
healthy. It waits for the pass to report the surplus it declined, so a pass that never
ran cannot read as a pass that declined.

  • Harness suite 89 waits for the app to be stoppable before replacing its data, and suite
    78 covers a spawner claim being withdrawn and the node's candidacy for that app coming
    back.

  • A node's static IP is now declared by the fleet rather than inherited from the
    wiring
    . hasPublicIpOnInterface() reads /proc/net/route for a default route, and the
    harness network is Internal, so docker gives node containers no route out and every
    node read DYNAMIC — making static_ip deferral unreachable and silently swapping which
    reason a test was waiting for. A suite that wants a static node now gets a real default
    route installed via the network's own gateway before FluxOS starts, and its absence is
    how a suite asks for a NAT'd node. The route failure is loud, because a swallowed one
    reads exactly like a node that was never asked for a route.

  • Full harness gate, 94 suites, MAXN=6, at 7523568a3: 94 passed, 0 failed.

    Run after Hosting an app is not owning it: the node operator loses every app-scoped verb, a specification decrypts for its owner alone, and the check that decides who is asking is rebuilt underneath #1791 merged into development and this branch was rippled onto Two Flux nodes on one public address can install onto the same port, and nothing refuses it #1795 at 18a705431.
    157 commits above development (37 + 120); the ripple verified by --numstat and per-commit
    patch-id rather than by the rebase's own report.

    The two suites that were red on an earlier gate are green. Suite 02 lost its after all hook to a
    harness teardown defect — the mongo client was closed after the mongo container had stopped, and
    mongodb 7.5.0 then waits out a full 30s server-selection timeout that squashError swallows,
    against a hook budget that is a flat 30s. Closing the client while the server is still up removes
    the precondition: 27 of 30 runs slow before, 0 of 30 after, and the db teardown step has not
    exceeded 11ms on any gate since, against one outlier of 32,831ms.

    Suite 96 asserts that a surplus copy which is also the writer is left alone and the next copy
    trimmed instead. The trim is permitted only when a 10s peer probe confirms the newest copy holds
    the writer, and the give-up pass runs every 44 blocks — so the drive gets one attempt, and an
    earlier gate lost it with the product declining for entirely the right reason. driveUntil now
    waits for that peer to be answering before it drives another block, so the chain cannot advance
    into a pass through a window in which the pass would have to refuse. The budget is unchanged:
    driving longer would have bought a second coin toss rather than removing the coin.

    The gate is re-run per revision and this section carries the latest result at the head it names.

@Cabecinha84 Cabecinha84 changed the title feat(dos): a residential node not running ArcaneOS is put into DOS feat(dos): DOS residential nodes not running ArcaneOS Aug 13, 2026
@MorningLightMountain713 MorningLightMountain713 changed the title feat(dos): DOS residential nodes not running ArcaneOS feat(dos): a residential node holds, evacuates, and only then goes to DOS Aug 19, 2026
@MorningLightMountain713
MorningLightMountain713 force-pushed the feat/dos-residential-non-arcane branch from f672954 to ad0faf3 Compare August 19, 2026 15:09
@MorningLightMountain713
MorningLightMountain713 removed their request for review August 20, 2026 19:40
@Cabecinha84

Copy link
Copy Markdown
Member Author

Verdict: don't ACK yet — two things to fix, the rest are notes

The design is unusually careful (three-state everywhere, "absence of evidence is not evidence", the
table-decides-or-nobody-does rule) and the test coverage is real. But there's one production regression that has
nothing to do with residential nodes, and one liveness gap in the feature's core purpose.


  1. Blocking: the static-IP verdict is frozen before the location table exists

geolocationService.js:387 decides publishedHosting inline, inside setNodeGeolocation, and stores the resulting
staticIpState. But the table is reliably not loaded at that moment:

  • serviceManager.js:576 calls ipLocationSync.startSync() only after await globalState.waitForDbReady().
  • dbReady is opened by appSyncOrchestrator.#rebuildDb() — after hash sync and a full reindexGlobalAppsInformation().
    Minutes, not milliseconds.
  • serviceManager.js:606 calls setNodeGeolocation() on the same tick, unawaited. It only defers on
    getLocalSocketAddress().

So publishedClassification() returns {consulted: false} in the boot pass, publishedHosting is false, and rows 3 and 5
of the decision table — "no public IP on the interface (NAT) + hosting range → STATIC" — never fire. A 1:1-NAT cloud
instance reads DYNAMIC. That's precisely the AWS/GCP/Azure population the PR body cites as the reason the table
replaces staticIpOrgs.

The pass then only re-runs every 3 days (or on an IP change), and every restart resets it. On development the same
node is STATIC via storedGeolocation.static = proxy || hosting. hwRequirements.js:130 throws for staticip: true apps
when isStaticIP() is false — so those nodes stop being eligible.

This is exactly the mistake the PR argues against one function later: getNetworkClassification() refuses to freeze a
verdict when the table wasn't consulted, and re-derives on demand. staticIpState should get the same treatment —
derive on read, or re-run the static decision when ipLocationStore becomes ready.

  1. Blocking-ish: an evacuating node can never hand back a g: app it is primary for

appEvacuationSafety.js:201 refuses with ELECTED_PRIMARY and "stand down first" — and nothing implements a stand-down.
grep -rn "standDown|stand down|stepDown" across ZelBack/src matches that error string and nothing else.

It compounds with the ordering: queueDelayMs sorts ascending with compareInstanceSeniority, so position 0 (shortest
wait) is the most senior instance, and masterSlaveApps picks the primary from the senior end. The node scheduled to
leave first is the one most likely to be permanently blocked.

The direction is safe (nothing is deleted), but the node stays in HOLD+EVACUATE forever, never reaches DOS, and logs a
warning every 12 passes indefinitely. For the 9 stateful 2-instance g: apps in your own blast-radius table, that's
the exact population the staging exists for. Either implement the stand-down, or say plainly in the PR that those
nodes need manual intervention.


Description/claim discrepancies — cheap to fix, but the PR body is the safety argument

  1. "the junior leaves, the senior copy still holds the world" is the opposite of what the code does. reasonToGiveUpApp
    deliberately reverses the comparator for SURPLUS (compareInstanceSeniority(b, a)) to pick the junior; evacuation
    does not, so the senior goes first. The code is coherent — the in-code comment about the replacement being the most
    junior explains why — but the prose says the reverse.
  2. residential:decided is not published on "every terminal path of a tick". Four return false paths publish nothing:
    !nodeReady, installed === null, installationInProgress, observedMs === null.
  3. getNetworkClassification()'s JSDoc still advertises source: 'node' — that branch was removed by if
    (!published.classification) return null. Its inline comment also says "~8% of hosts" where the body says ~13%.
  4. checkAndRemoveApplicationInstance's new docstring says "spaced by config.fluxapps.removal.delay" — that
    serviceHelper.delay() was deleted and replaced with return.

Behavioural changes I'd want explicitly signed off (not defects)

  1. No in-FluxOS kill switch. config.residentialDos.enabled from commit d89eae1 is gone from the final state — no
    enabled in default.js or the service. In practice the real off switch is better: blank orgClasses in the policy
    repo and every node loses its RESIDENTIAL verdict within a day, no release. Worth stating, because otherwise the
    fallback reads as "revert 45 files".
  2. An empty residential non-Arcane node is DOSed on the first decisive tick with no settling window — applyDos() sits
    above noteVerdictConfirmed(). Deliberate and tested ("an empty node never enters the window at all"), and it
    deletes nothing, but one bad orgclass-overrides.json row puts every empty node in that org out of service in
    minutes rather than 24h.
  3. isDataCenter() changes meaning fleet-wide — was hosting || org ∈ staticIpOrgs, now a positive DATACENTER verdict.
    Hosting hosts with access-network PTR vocabulary become CONFLICTED and lose datacenter: true eligibility (your own
    header says 2 of 1,569); and on the stats.runonflux.io fallback there are no contradiction signals at all → UNKNOWN
    → false, where the old path read the field from the payload.
  4. Surplus removal is now syncthing-gated fleet-wide. Correct in direction, but stateful apps can sit at N+1
    indefinitely whenever syncthing can't answer, and the 12-refusal warning is the only signal — nothing acts on it.
  5. 49 single-instance stateless apps lose their only copy on departure. Per the design the spawner rebuilds from
    spec, but it's a real outage window, and if placement is constrained (geo pin, hw, enterprise ownership) it stays
    down.
  6. residentialNodeDosService calls clearPlacementHold() unconditionally rather than only when it owns the hold. Fine
    today (sole owner), but the DOS slot got a full ownership protocol and this one didn't.

What I verified myself

  • Unit tests: 5,466 passing, 5 pending, 23 failing. All 23 failures are dockerService tests needing a live docker
    daemon (Cannot read properties of undefined (reading 'Id')) — environmental, not caused by this branch. Nothing in
    the touched suites fails: the 13 relevant files give 795 passing, 0 failing.
  • eslint is clean on residentialNodeDosService.js, appEvacuationSafety.js, networkClassifier.js,
    geolocationService.js.
  • Config is production-neutral: locationTtlS/installingTtlS/sigtermExpiryS go live at values identical to the
    constants they replace; installErrorTtlS 3600→86400 matches the 24h the code has actually been running;
    explorerPollIntervalMs defaults to the current 5000.
  • ipLocationStore degrades safely: a pre-existing ingest marker carries no orgClasses → empty map → networkClass: null
    → nothing enforces until the next artifact fetch.
  • The two reverts at branch head cleanly back out the CloudUI commits — no residue in the diff.

Bottom line: ask for #1 and #2, plus the four prose fixes. #1 is the one I'd insist on — it's a regression that lands
on cloud nodes that have nothing to do with residential enforcement, and it's the same class of bug the rest of this
PR is built to avoid.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.92357% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.77%. Comparing base (84eb20a) to head (cba4798).

Files with missing lines Patch % Lines
ZelBack/src/services/residentialNodeDosService.js 95.41% 11 Missing ⚠️
...vices/appMonitoring/syncthingFolderStateMachine.js 75.00% 5 Missing ⚠️
...ack/src/services/appLifecycle/advancedWorkflows.js 97.72% 4 Missing ⚠️
ZelBack/src/services/appLifecycle/appSpawner.js 92.85% 3 Missing ⚠️
ZelBack/src/services/explorerService.js 25.00% 3 Missing ⚠️
ZelBack/src/services/serviceManager.js 25.00% 3 Missing ⚠️
ZelBack/src/services/geolocationService.js 97.84% 2 Missing ⚠️
...lBack/src/services/appPlacement/ipLocationStore.js 94.44% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           fix/same-ip-port-collision    #1784      +/-   ##
==============================================================
+ Coverage                       66.94%   67.77%   +0.83%     
==============================================================
  Files                             185      188       +3     
  Lines                           34448    35087     +639     
==============================================================
+ Hits                            23061    23780     +719     
+ Misses                          11387    11307      -80     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

Thanks — this was a careful read, and two of the twelve changed the design rather than the code. Everything below was checked against the source rather than taken or dismissed; where you were right about a mechanism but wrong about its size, I have said so and given the measurement.

Tip is 7d2d004e7. Unit 5,493 passing / 18 pending / 0 failing. Full harness gate 74/74, on a clean box at MAXN=6 — including 21-spawner-deferrals and 45-reconciler-stop-during-stream-outage, the two this branch previously listed as failing for other reasons. The PR body is rewritten.


1. Static IP frozen before the table exists — right, and it ended the rule

Verified true in every link. serviceManager.js:606 calls setNodeGeolocation() on the same tick as an unawaited startDbDependentServices(), which gates ipLocationSync.startSync() behind waitForDbReady(), which only opens after reindexGlobalAppsInformation(). Nothing re-derives it, and a restart re-freezes it wrong.

The population is not what you named. Matching every hyperscaler ASN against the fleet gives one slot, an Oracle box. The six "Google" hits are Google Fiber — a consumer ISP.

That made the fix worth questioning rather than writing, and the answer went further than deriving-on-read. We measured what "static IP" currently means, by SSH, on a random sample of 500 ArcaneOS slots (442 reachable), reproducing hasPublicIpOnInterface() on each node:

how the node reaches the world slots share claim staticIp today
Public address on its own interface 102 23.1% 102 (100%)
Behind a UPnP router 336 76.0% 126 (38%)
NAT, no UPnP 4 0.9% 0

Of the 228 slots advertising staticIp: true, 126 — 55% — are behind a UPnP router on an RFC1918 address, qualifying entirely through an ip-api flag about the RANGE: 112 on hosting, 12 on proxy, 2 with no ip-api record at all.

So the rule changed rather than its timing. A node is static only if it holds a public address on an interface — no table, no vendor flag, no operator name confers it. publishedHosting and its branch are deleted; the decision table collapses from six rows to four.

The reasoning is in the body, but briefly: staticip is two promises — directly connected and not seen to move — and a range flag answers neither. It cannot see a UPnP mapping, which lapses on a router reboot independently of the address; and "this block belongs to a hosting company" does not mean your address is fixed, since an operator reassigns on rebuild or migration and you return on a different address in the same range, still reading DATACENTER.

Cost, measured: ~1,550 Arcane slots stop claiming static (95% CI 1,321–1,779), the pool goes 2,800 → ~1,255, and nobody gains — all 102 public-address slots already claim it. 44 apps require staticip; 74 placements sit on slots that lose it, and nothing evicts them, because checkAppStaticIpRequirements is reachable only from checkAppRequirements (install and redeploy). They drain by attrition.

The genuine 1:1-NAT case you were pointing at is knowingly refused: ~49 slots fleet-wide, none of which claims static today.

Census, method and bounds: investigations/PR1784_STATIC_IP_IS_A_RANGE_FLAG_NOT_AN_OBSERVATION.md.

2. An evacuating node could never hand back a g: app it is primary for — right, and now implemented

Confirmed, including that nothing implemented a stand-down. Your sizing was low, but so was mine: it is not the 9 stateful 2-instance apps. Being stuck needs only that the node is primary for a g: app, so an app with two copies in data centres and its primary on a residential node is stuck too. Measured properly: of 91 enforceable hosts, 51 must actually drain, and 26 of those 51 — 51% — are primary for at least one g: app. 56 apps affected.

There was no missing mechanism, only a wrong order of questions. masterSlaveApps already handles a primary disappearing and syncthingFolderStateMachine already demotes on an unpromoted pass; the gate simply asked "am I the primary?" before "does a peer hold a full copy?", refused on the first and never reached the second.

  • appEvacuationSafety now runs the synced-peer loop before the election question. That ordering is the safety property: a node holding the only good copy refuses there and never reaches the primary question, so standing down can never strand an app.
  • The primary case returns STAND_DOWN_REQUIRED with the components to stop, instead of the dead-end refusal.
  • The give-up pass stops those components, marks them, and returns — one action per pass.
  • masterSlaveApps skips a stood-down component. That is the mechanism, not bookkeeping: without it the election restores the component within one cycle and every cycle after.
  • Capped at 6 passes, because a component stopped here and running nowhere is worse than stuck-but-serving.

Deliberately not setOperatorStopped — that lock is the operator's and it is permanent.

Worth recording how this was proven, because it bears on the rest: the harness caught a defect nine mutation-proven unit tests could not, by construction, because they stub the collaborators whose real interaction was the bug. The stand-down stopped the container but left controllerDesired at 'running', so appReconciler restarted it on the next sweep — and the node then went permanently ELECTION_UNKNOWN, which is worse than the state being fixed. setControllerDesired(identifier, 'stopped', …) now precedes the container stop. Suite 55 is 17/17 on a live fleet.

3. The junior/senior discrepancy — real, and it is a code fix

You filed this as prose, and one item earlier you wrote the reason it is not: "the node scheduled to leave first is the one most likely to be permanently blocked."

The body describes the right design; queueDelayMs had the comparator the wrong way round. It is reversed — the same reversal reasonToGiveUpApp already applies for SURPLUS, so the two paths now answer "which copy stands aside?" the same way, the newest copy leaves first, and the node that has to stand down goes last rather than first.

Position is a correlation, not a guarantee — a remembered previous primary keeps the role without the order being consulted, higher indexes start on a stagger, and a designated leader can seed outside it. The argument does not need more than the correlation.

4. residential:decided — this one is wrong, and no change was made

publishDecision({ residential, arcaneOs, enforce: shouldEnforce, undecidedBecause: null }) sits at residentialNodeDosService.js:534, above all four returns you list — !nodeReady (548), installed === null (558), installationInProgress (574), observedMs === null (584). The two earlier bail-outs each publish their own. So every terminal path of a tick publishes exactly once; what those four lack is a publish statement inside them, which is not the same thing.

There is a weaker true version: all four publish the same enforce: true as a complete tick, so the event cannot distinguish "hold applied and drain proceeded" from "hold applied, drain skipped this tick". That is a coarse test signal — fluxEventBus is inert on a real node — and the giveUp:* events already say precisely what happened to each app, so we have left it.

5, 6. Both correct, both fixed

getNetworkClassification's JSDoc no longer advertises source: 'node'; that branch went with the fallback, and the @returns now names "table holds no verdict" as one of the ways null comes back.

The 8%-vs-13% was two denominators, and the 8% has no source — it arrived with the commit that wrote the comment and appears nowhere else. The comment now carries the body's figure with the denominator written out beside it: 70 of the 541 non-ArcaneOS slots.

checkAndRemoveApplicationInstance's docstring no longer names config.fluxapps.removal.delay. Nothing reads that key — the docstring was its only mention in ZelBack/src. The comment now says what actually paces the pass and why the replacement is better: the old serviceHelper.delay() sat inside a loop, so it held the whole pass open, every app behind the removed one waited on it, and every decision after it was made against an installed-app list read minutes earlier. The pass is also fired unawaited from the block handler, so one long enough to outlive its own interval could overlap the next.

7. No kill switch — agreed, and your version of it is in the body

Deliberate, and your observation is the right one: blanking orgClasses ends enforcement fleet-wide within a day with no release, no node upgrades and nothing to roll back, which a config flag could not do. It is now stated at the top of the body, precisely so the fallback does not read as "revert 45 files".

8. Empty node DOSed with no settling window — confirmed intended

The window exists to protect customer data and an empty node has none; the DOS deletes nothing and is released the moment the node is attested again. Your operational point stands and is now in the body: a mistaken policy row reaches empty nodes in minutes rather than a day.

One framing note. This one, and 7, 11 and 12, are not behavioural changes — residential DOS, the settling window, evacuation and the placement hold are all new here, so there is no prior behaviour to compare against. 9 and 10 genuinely are changes to existing behaviour, and we have treated them as such.

9. isDataCenter() — measured, and it is looser, not stricter

The mechanism is exactly as you describe. The direction is the opposite of what the item implies, and it took a measurement to see that. Both predicates transcribed from the code and run over the same captured ip-api and reverse-DNS data for 2,432 fleet hosts:

hosts qualifying as data centre
development 1,570
this branch 1,673

105 hosts gain eligibility — ip-api never said "hosting" but the operator sells hosting (38), the reverse-DNS name says vps/cloud/server (37), or a combination. 2 lose it: 212.83.170.245 (Scaleway) and 213.44.137.57 (Bouygues), both hosting addresses whose reverse DNS carries access-network vocabulary, so the classifier reads CONFLICTED rather than guessing. That is the "2 of 1,569" you were reaching for, and it is the whole of the downside.

On the stats fallback you are describing a pre-existing state, not a regression. /fluxlocation returns ten fields — ip, continent, continentCode, country, countryCode, region, regionName, lat, lon, org — and has never carried dataCenter or static. The old path read statsRes.data.data.dataCenter, a field that does not exist, so it was always undefined and always falsy. A node on that fallback has never been eligible for data-centre apps. What this branch changes is that the reason is now explicit instead of accidental. We found it because a commit here fixed the harness stub, which had been synthesising both fields and making the fallback look richer in tests than it is on any real node.

The measurement did surface one genuine defect, which is fixed in 78d5d02b7. proxy was reaching DATACENTER on its own: 8 of the 2,432 hosts had the proxy flag and nothing else — no hosting flag, no hosting operator, nothing in the PTR. That is a VPN artefact, and a home machine behind a VPN exit carries exactly that signature, which is what an owner buying datacenter: true is paying to avoid. It is also the same category error this branch removes from static IP one function away. A DATACENTER verdict now rests on positive hosting evidence; proxy still contradicts a residential reading, so the veto is untouched, it just cannot confer the opposite. New total 1,673; the 27 hosts carrying proxy beside a hosting operator keep it through the operator.

10, 11, 12. Correct

10 — SURPLUS and EVACUATION share one gate, so this is fleet-wide, and a stateful surplus copy can persist while syncthing cannot prove a peer holds the folder. That is the trade against deleting a last remaining copy, and the 12-refusal warning is indeed the only signal. Stated in the body.

11 — Correct, and now stated as a real outage window rather than only as an intended one, including that it is longer where placement is constrained by a geo pin, hardware or enterprise ownership.

12 — Correct, and inert: setPlacementHold and clearPlacementHold have exactly one caller each, so there is no other owner to trample. Left as is.

Your own verification

Re-checked and sound. locationTtlS 7500 is the old 125*60*1000, installingTtlS 900, sigtermExpiryS 420, explorerPollIntervalMs 5000 is the hardcoded value, and installErrorTtlS 3600→86400 matches the 24h the code actually ran while the key was unread. The 23 dockerService failures you saw are the missing docker daemon; the suite is green with it running.

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things I'd want explicitly signed off — not defects, scope

  1. An unsigned third-party artifact now drives a destructive path

config.policy.baseUrl is https://raw.githubusercontent.com/RunOnFlux/fluxos-network-policy/main. There is no
signature, no pin, no checksum on the artifact — trust is TLS plus GitHub plus write access to that repo. That was
already true on development, but there a bad orgClasses row costs placement accuracy. Here the same unsigned bytes
drive HOLD → EVACUATE → DOS ≥ 100, and nodeStatusMonitor.js:66 turns isNodeDos() into removeAllAppsLocally(...,
force=true), which is sudo rm -rf on appvolumes.

The design bounds this well, and I want to say so precisely rather than alarm you:

  • enforcement needs residential && !arcaneOs, and isArcaneOs() is read from fluxbenchd, so a fully malicious table
    reaches at most the 541 non-ArcaneOS slots with readable bench; null bench decides nothing;
  • DOS is only applied on installed.length === 0, so the destructive step finds nothing;
  • the node veto can only ever remove a node from enforcement, never impose one;
  • it self-heals — fix the row, next tick clears the hold, the sticky DOS and the settle marker.

But the trust boundary on that repo just moved from "affects placement quality" to "can take non-ArcaneOS nodes out of
service fleet-wide". That's worth a line in the PR body and a conscious sign-off, not a silent inheritance. The PR
body sells the same property as the kill switch ("blank orgClasses and it ends within a day"), which is exactly right
— and the same lever pointed the other way is the risk.

  1. This is the first removal path in FluxOS that deletes the last copy of an app

appEvacuationSafety.js returns { safe: true, code: 'STATELESS' } before the otherHosts < 1 check, so a stateless app
with instances: 1 and one location — this node — passes the gate, and removeAppLocally(name, null, false, true, true)
deletes the container and the volume (appUninstaller.js:122,226).

SURPLUS has never done this: it requires runningAppList.length > minInstances. The geolocation-redeploy path does, and
the module header cites it as one of the two paths that "have already destroyed customer volumes".

The PR body's justification is "None of the 49 single-instance apps hold synced data — removing one loses a container,
not data." That equates "no syncthing sync mode in containerData" with "holds nothing worth keeping". It's a
defensible product assumption but it is an assumption, and it's the largest residual customer-facing risk in the
change. I'd want it stated in those terms — 49 apps, volume deleted, rebuilt empty from spec elsewhere — rather than
as "a container, not data".

  1. getMasterIpFromFdm's fdmOk fix changes g: failover fleet-wide

return { ip: null, fdmOk: true } → return { ip: null, fdmOk: answered }. It's the correct fix — if (!fdmOk) continue
in masterSlaveApps was dead code — but making it live means that during a total FDM outage no node elects a primary
for any g: app, where previously a node could take over once checkPeersRunning('all') cleared. A g: app whose primary
dies during an FDM outage now stays down.

That's arguably the right trade (it's the split-brain guard), it's what the evacuation gate needs, and the harness
covers it — but it has nothing to do with residential nodes and it isn't in the PR body's "Behaviour this changes for
every node" list. It should be.


Two small fixes I'd ask for

a. getInterfaceIp reads only the first address on the interface.
for (const addr of iface) {
if (addr.family === 'IPv4' && !addr.internal) return addr.address; // first wins
}
A host with a private primary and a public secondary alias on the default-route interface reads false → DYNAMIC →
loses staticip eligibility. The whole static rule now rests on this one function, so it should scan every address on
the interface and return the first routable one rather than the first non-internal one. One line.

While you're there: the 442-slot census reproduced hasPublicIpOnInterface() over SSH on the host. That's the same
namespace FluxOS runs in on ArcaneOS, so the figure is sound there — but it's worth one sentence confirming there's no
supported deployment where FluxOS reads a container's /proc/net/route, because that would read false for every such
node.

b. getNodeGeolocation() restores staticIp from the database but derives staticIpState separately.
({ geolocation: storedGeolocation, staticIp, dataCenter, ... } = dbData);
staticIpState = dbData.staticIpState ?? STATIC_IP_STATE.UNKNOWN;
On upgrade, a record written by the old proxy || hosting rule restores staticIp: true while staticIpState falls back
to UNKNOWN. isStaticIP() and getStaticIpState() then disagree until the first setNodeGeolocation pass completes — and
stay disagreeing on the 5-minute retry loop if both ip-api and the stats fallback fail. Derive staticIp from the
restored state instead of trusting the stored boolean.

(Related trivia: getStaticIpState is exported and has no consumer outside tests.)


Three prose/code mismatches still in the body

"Instances are counted per host, not per registration." Only otherHostCount is. The BELOW_INSTANCE_COUNT serialisation
gate and reasonToGiveUpApp's SURPLUS test both use raw locations.length. Safe direction for stateful apps — ONLY_HOST
catches the co-located pair — but a stateless app whose "two instances" are two registrations on one residential line
passes the count gate as if it had two hosts.

"a node acts only while it observes the app at full strength, so the first departure makes every other evacuating
holder wait." Not the implemented mechanism. mayEvacuateApp stamps and accrues wholeSince regardless of the app's
strength; nothing checks fullness during the wait. What actually serialises is the safety gate refusing with
BELOW_INSTANCE_COUNT at the moment of action, after which forgetAppObservation resets that app's clock to zero. The
property holds and is more conservative than advertised — every other holder re-serves its full base + position × step
after the app returns to strength — but the described mechanism isn't the one in the code.

Minor, harness-only: applyDos() calls setStickyDosMessage then setStickyDosStateValue, and each publishes. The first
dos:changed therefore carries the new message with dosState: 0. A consumer keying on the message rather than the state
sees an inconsistent first event.

@MorningLightMountain713
MorningLightMountain713 force-pushed the feat/dos-residential-non-arcane branch from 7d2d004 to 44384f6 Compare August 24, 2026 09:43
@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

All eight items serviced: the three sign-offs are made and stated in the body, both fixes are in (one in a different direction, argued below), two of the three mismatches were body defects and are corrected, and the third is deliberate.

The three sign-offs

1. The unsigned artifact. Signed off, consciously, and the body now carries a paragraph stating the trust boundary rather than inheriting it silently ("The trust boundary this inherits"). Two things worth adding to your framing. First, the root this replaces is weaker than the one it installs: today's classification rests on http://ip-api.com — plaintext HTTP, forgeable per node by any on-path party, with no history and nothing to audit — while every byte the policy repo has ever served is in its git history behind branch protection (no force-push, admins bound, a required validate check; merges do not yet require a second reviewer, and that is the honest residual). Second, signed manifests in the fluxhashes scheme are in progress and are the successor; this artifact adopts that signing when it lands.

2. The last copy. The body paragraph is rewritten, and it now claims more than you asked for rather than less. An owner who deploys instances: 1 chose a service with no continuity guarantee, and volume deletion is not a property of this path: every departure the platform has — surplus trim (which deletes the junior instance's volume today, through the same removeAppLocally), geolocation redeploy, node loss — removes the instance and its volume, and nothing anywhere preserves an unsynced volume. What this path changes is the outage window, and it changes it downward: the removal is broadcast immediately, so the spawner rebuilds at once, where a node that simply dies leaves its stale location record holding the app at apparent full strength for up to 125 minutes (the running-location TTL) before anyone acts. Evacuation is the gentlest departure the platform has.

3. fdmOk. Added to the body's "Behaviour this changes for every node" list. One addition to your framing of the trade: the node the old behaviour promoted was one that could not reach three services on three continents — at least as likely to be network-isolated itself as witnessing a global outage, which is exactly the split-brain writer the election exists to prevent. During a genuinely global FDM outage the failover being given up was not being routed to anyway.

The two fixes

a. getInterfaceIp. Fixed as you described: the scan skips non-routable addresses and returns the first routable one, the caller's now-redundant routability check is folded in, and the test pins the private-primary/public-secondary case (written to fail on the old code first). On your census question: there is no supported deployment where FluxOS reads a container's /proc/net/route — it runs on the host on both platforms, as a plain process on legacy nodes and as the fluxos.service systemd unit on ArcaneOS — so the over-SSH reproduction is sound for the whole fleet.

b. getNodeGeolocation. Taken, but in the opposite direction, because deriving staticIp from the restored state opens a destructive window. checkAppStaticIpRequirements throws on mismatch inside the soft-redeploy and update-reinstall paths, and their catch responds to any requirements throw with removeAppLocally — container and volume. Under "restore as UNKNOWN, not static", every legitimately-static node passes through that window on upgrade day, and any staticip app update or redeploy landing inside it removes the instance. Meanwhile the disagreement being fixed is invisible in production: as your own trivia notes, getStaticIpState has no consumer outside tests, so nothing ever observes the two answers differ. The fix instead backfills the missing state from the stored boolean — STATIC as a carried prior the first refresh pass replaces, stored false as UNKNOWN since an old record cannot tell dynamic from never-checked — so the pair agrees by construction and no window opens. The underlying class (requirements checks that destroy on missing information in the redeploy paths) pre-exists this branch — a freshly booted node reads staticIp: false before its first geolocation pass on development too — and is tracked with the geolocation-wipe work, where geo and staticip get fixed together.

The three mismatches

"Counted per host." A body defect, corrected — and the invariant is stronger than either of us stated. The spawner refuses to place an instance on an IP that already holds one ("already running on this Flux IP"), and an app's fixed ports make a second copy on one machine moot regardless, so a given app's instances land one per host by construction: the two-registrations-on-one-line scenario is not reachable through placement. The safety gate's per-host dedupe covers the one way a pair can converge afterwards — an IP change — and that gate is the one that guards data. The strength count and SURPLUS counting registrations is, under the placement rule, the same number.

"Acts only at full strength." A body defect, corrected to the implemented mechanism: a departure leaves the app short, every other holder's gate refuses to act on a short app and resets that app's queue clock, so each further departure waits for the spawner to restore full strength and then serves its full wait again. As you say, the property holds and is more conservative than the old sentence advertised.

applyDos ordering. Deliberate, and no change: dosState is the operative field and the message is display text, so message-first means a state-keyed consumer sees exactly one transition, fully consistent — state 100 arriving with the message already right. Swapping the calls would hand that consumer state 100 paired with a stale message, which is worse for the consumer that is keying correctly. Inert in production either way (fluxEventBus publishes only under config.testEventStream).

Branch state: rebased onto current development (64 commits, conflicts in two files resolved, one silently-merged duplicate declaration caught and folded back), both fixes committed, unit suite green.

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  1. The serialisation between two evacuating holders is temporal, not atomic — this is the one I'd hold the ACK on

The body's termination argument is: "a departure leaves the app short, every other holder's gate refuses to act on a
short app and resets that app's queue clock."

That property only holds if no two holders' tickets mature in the same give-up pass. They can:

  • The pass is fired from the block handler, so every node in the fleet runs it within seconds of the same block. A
    removal in pass P cannot be visible to another node in pass P — fluxappremoved is broadcast after the volume is
    already gone, and both nodes read registryManager.appLocation at the top of the same pass.
  • The queue step separates first maturity (different position → different duration). It does not separate
    re-readiness. mayEvacuateApp returns early on the EVACUATION_INTERVAL_MS (6h) check before it touches wholeSince
    (residentialNodeDosService.js:365-371), so a node whose ticket matured while it was blocked by its own departure
    interval is ready the instant that interval expires. Two nodes whose 6h intervals expire in the same 22-minute pass,
    both mature for the same app, both remove.

The harness test knows this. 55-residential-node-evacuation.js:757 says the tickets fix "a different DURATION per
node, not a different deadline" and rests on "In production both holders start observing together." They don't:
wholeSince is stamped on each node's first give-up pass after its own settling window closes, and those windows open
whenever each node first observed the verdict — days apart across the fleet.

Blast radius today is empty, structurally it isn't. For g: apps the election saves it: the primary gets
STAND_DOWN_REQUIRED and cannot remove in that pass, so the two can't go together — and the body says all 9 stateful
2-instance apps are g:. An r:-only synced app with 2 instances both on residential nodes has no such guard:
canSafelyRemoveApp filters gComponents to empty, skips the election entirely, and each node is the other's
findSyncedPeer at 100%. Both return SYNCED_ELSEWHERE, both delete. Zero such apps in the measured population, nothing
in the code that keeps it that way.

Cheapest fix (~3 lines): in mayEvacuateApp, drop the app's wholeSince entry when the departure-interval gate blocks,
so the position-based ticket is served afresh once the interval clears. That restores position as the separator after
every interval block, which is what the design intends. A per-node deterministic jitter folded into queueDelayMs would
also work.

  1. wholeSince never observes wholeness

The comment at residentialNodeDosService.js:88 and the body both say a node may act "only once it has seen that app at
full strength for base + position × step." The clock actually starts the first time mayEvacuateApp is asked about the
app, and nothing checks instance count until the ticket matures — the wholeness test lives in canSafelyRemoveApp,
which is only reached once giveUp is already true. An app short for the entire wait still serves the full wait, then
takes one BELOW_INSTANCE_COUNT refusal and resets.

Outcome-equivalent in most traces, but it is a weaker guarantee than the one stated, and it is the same class of
prose/code gap the previous round already corrected four of. Fix the comment or check strength at stamp time.

  1. SURPLUS + elected primary refuses forever (minor liveness)

STAND_DOWN_REQUIRED is only acted on when decision.reason === 'EVACUATION' (advancedWorkflows.js:3243). The comment
argues a surplus giver-up is never the primary because surplus picks the junior and the primary is senior — but this
branch's own note elsewhere says position is "a correlation, not a guarantee" (a remembered previous primary keeps the
role, a designated leader seeds outside the order). When it isn't, that node refuses that app every pass
indefinitely, surfacing only as a warning every 12 passes, and the surplus is never trimmed. Not destructive. Either
wire the stand-down there too, or state why the correlation is a guarantee for SURPLUS specifically.

  1. HOLD and EVACUATE are invisible to the operator

The placement hold and the drain produce logs only. getPlacementHold() has exactly one consumer (appSpawner), and
/flux/info carries dos — so an operator sees nothing until the final DOS stage, by which time the node is already
empty. 83 hosts / 218 slots are going to watch apps leave with no readable reason. info.flux.placementHold (plus the
settle/evacuation state) is a few lines and is the difference between self-service and a support queue.

  1. The static-IP change is wider than the staticip gate — worth an explicit sign-off

isStaticIP() also drives the spawner deferral (appSpawner.js:667, spawnDeferrals.staticIpMs): a node claiming static
defers apps that don't need static, to stay available for apps that do. ~1,550 slots stop deferring on upgrade day.
That is a fleet-wide placement shift the body doesn't cost — arguably a correction, but it isn't only "44 apps, 74
placements."

Second-order: the 10-day window is a new penalty for genuinely static nodes that change address. On development a
hosting-range node recovered static on the very next pass through proxy || hosting; now it reads UNKNOWN for the full
ten days, and checkAppStaticIpRequirements is a hard gate throughout.

  1. The attrition path deletes volumes and bypasses the new safety gate — needs a number

The body's "nothing evicts those 74 … they drain by attrition" is confirmed, and the shape is worth naming: in
reinstallOldApplications, removeAppLocally(name, null, true, false) — force, volume included — runs before
checkAppRequirements (advancedWorkflows.js:3461 and :3478). So those removals never see appEvacuationSafety.

How many of the 44 staticip apps carry synced components? If the answer is zero, say so and it's closed. If not,
upgrade day starts a slow, unguarded volume-deletion drip — the exact failure mode the rest of this PR was built to
prevent. The mechanism is pre-existing; the trigger is new.

  1. The node veto is a free evasion, and the "Operator evasion" section doesn't mention it

getNetworkClassification vetoes a published RESIDENTIAL on any evidenceAgainst, and evidenceAgainst includes ip-api
proxy. A residential operator who routes the node through a commercial VPN picks up proxy (usually a hosting-range
address too) and is permanently CONFLICTED → never enforced. It fails open, so it's not a safety bug — but it is the
cheapest evasion available, it costs the operator nothing, and the body currently discusses only config-hashing and
mongo tampering.


One caveat on verification

Every fleet number in the body — the 541/218/83 blast radius, the 442-slot SSH census, the 1,570 → 1,673 datacentre
count — cites fluxModels/investigations/PR1784_*.md, which is not in this repo or this PR. I could verify the code,
the tests, and the mechanisms; I could not verify a single measurement. If those files aren't reachable to whoever
ACKs, the quantitative half of the safety argument is being taken on trust.


Verdict

Don't ACK yet — but it's close, and the gap is small.

The staging is sound, the three-state discipline is real, and the two fixes from the last round
(peer-check-before-election, and static-IP as an observation) genuinely changed the design for the better rather than
papering over the reports. Nothing I found is in the class of "this deletes data today."

What I'd ask for before ACK:

  1. #1 — reset wholeSince when the departure-interval gate blocks (or add jitter). Small, and it closes the one place
    where the body claims a property the code doesn't have on a path that deletes volumes.
  2. #6 — one number: how many of the 44 staticip apps are stateful.
  3. #4 — expose the placement hold. Cheap, and this feature is otherwise silent to the people it acts on.

Then #2 and #3 as prose-or-code cleanups, and #5 and #7 as things to state in the body rather than change.

@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

All seven checked against the branch. Every one was accurate about the code. Four are fixed; three are answered in the body, which is updated and live.

1 and 2 — the ticket (2eb325368)

Both accurate, and the same defect from two sides, so one fix closes them.

mayEvacuateApp now measures an uninterrupted observation rather than elapsed time, on the monotonic clock, and the accounting sits below the interval gate. A blocked node records nothing against its tickets, so its own departure interval reads afterwards as one long gap and every ticket starts again — the effect your fix asked for, reached by making the ticket mean what its comment already claimed.

Strength is tested where the clock is stamped, so an app short of its instance count accrues nothing towards its turn. That closes finding 2 in code rather than prose, and makes the termination argument in the body true as written rather than true in most traces.

lastEvacuationAt moved to the monotonic clock with it, and to null for "no departure recorded". On that clock 0 is the start of this process, so the old now - 0 idiom would have held the gate shut on a fresh node for six hours rather than open. The persisted marker stays wall-clock and is converted on restore.

3 — SURPLUS and the elected primary (a8c0a2b40)

Fixed, but deliberately not by wiring the stand-down into SURPLUS.

Standing the primary down to trim a surplus stops the one node writing the volume in order to make a count come out. "The newest copy stands aside" is a stand-in for "the least valuable copy stands aside", and when the newest copy is the writer the stand-in is backwards: it is the most valuable copy on the network. An app that is over-served is not down; an app whose writer was stopped to tidy a count is.

So the newest copy stays when it is running the writer, and the second-newest trims instead — on a positive confirmation, through the same peer probe the election already uses. That probe was lifted out of masterSlaveApps rather than copied: two implementations of "is that peer running it" drift towards whatever answer each caller finds convenient, and for this caller that answer is a removal. Silence, timeout, refusal and "not running" all mean nothing is trimmed.

Every node ranks the same shared order, but "who is writing" is each node's own reading and FDM's registration lags it, so the rule can only fail towards no trim, never towards two. The liveness you flagged is gone — the surplus is trimmed on the pass, by the copy behind the writer.

Both refusals now report themselves (7afabaf1c), which belongs with your finding 4 as much as this one. The newest copy declining because it holds the writer reports SURPLUS with giveUp: false; the copy behind it, declining because it could not confirm that, reports SURPLUS with code: 'WRITER_UNCONFIRMED' and the state the probe returned. The second used to report NONE — indistinguishable from an app with no surplus at all, which left the one observation that would catch this rule failing open reading like a quiet pass over a healthy app. It is recorded on the way through rather than returned, so a node that is also evacuating still reaches the evacuation gate below it.

Both halves of the rule run on a live fleet in 96-surplus-trim-and-the-writer: the writer's own node declining to stand aside, and the copy behind it trimming once it has confirmed the writer — and, separately, declining to trim while it cannot reach the node it would have to confirm.

4 — the staging is on /flux/info (605bc8589)

info.flux.dosStaging is null | "HOLD" | "EVACUATE", always present so it can be counted across the fleet rather than inferred from its absence.

It is derived from the settling window, not from evacuating. That flag is a per-tick permission for the give-up pass and any undecidable tick turns it off, so reporting it as a stage showed the node moving backwards through a staging it never moves backwards through.

The refusal counter and its 12-pass escalation are also back, keyed on a machine-readable code on the pacing verdict rather than on matching prose. Moving the strength test into the pacing gate had refused a short app before appEvacuationSafety was consulted, which took that warning with it — and it is the one a node stuck holding an app the fleet cannot bring back to strength depends on.

5 — the deferral shift

In the body, under What else moves with it: the ~1,550 slots that stop deferring on upgrade day, why that is a correction rather than a regression, and the static pool that remains. The second-order point is there too — a genuinely static node that changes address now reads UNKNOWN for the full ten-day window, checkAppStaticIpRequirements being a hard gate throughout.

6 — the number

Of the apps requiring staticip, 3 carry synced componentsdcmsbackend, teamspostposterbackend42111, wordpress1714731445112. All three are r:, all at instances: 3, 9 placements between them. Of those 9, 2 sit on nodes that lose static under the new rule; the other 7 hold a public address and keep it.

So the bound is one of three copies, on two apps, re-synced from the two survivors — not the failure mode the rest of this PR was built to prevent. Every other app requiring staticip keeps no synced state at all, so an attrition removal costs it a container the spawner rebuilds from the specification.

The ordering itself stays as it is. Moving checkAppRequirements above the delete does not fix it — it converts the deletion into a node stuck on a stale spec forever, because a node that keeps serving is never excluded from the instance count. The fix is a make-before-break drain, where a node that can no longer satisfy a spec keeps serving the old one and keeps its data while being excluded from the counts that decide whether the network needs another instance. That is designed and lands in a subsequent PR; it is stated in the body rather than half-done here.

7 — the node veto

Now in Operator evasion, as the cheapest evasion available and failing open by design, with why closing it would mean enforcing on contested evidence — the trade this design refuses everywhere else — and why it is not free to the operator either.

On verification

Fair, and taken. The body no longer cites anything you cannot reach: every figure in it is reproducible from the published artifact nodes actually fetch, read with this branch's own ipLocationStore. The per-host lists behind the counts — every host counted residential, and every host with no evidence either way — are available on request.

@Cabecinha84

Copy link
Copy Markdown
Member Author

Blockers

  1. The branch does not merge, and the conflict is in the wrong file.
    mergeStateStatus: DIRTY. Six files conflict; advancedWorkflows.js alone has ~10 hunks. Worse than textual: development
    independently landed 0b991c4 fix(election): FDM's silence is told apart from FDM saying there is no primary — the
    same fdmOk: answered fix this PR carries — plus 3c8a24b (g: election reads busy state at the decision) and e8fb86b
    (elected container started only where FDM confirmed). Those are precisely the mechanics isElectedPrimaryHere and the
    STAND_DOWN_REQUIRED path depend on. This needs a real rebase with a careful hand-resolution, not a merge commit — and
    the unit suite re-run after it.

  2. No gate result at this head. The 74/74 harness gate was reported at tip 7d2d004. The last ~15 commits are all
    harness work (suites 55, 96, 13, 62, framework driveUntil, stub-peer waits). Same condition you set on Reconciler: the only actuator for every operator command, and a ladder that paces faults not operators #1780 and
    Test harness isolation: the fleet installs from itself and reaches nothing outside — and four production fixes it turned up #1781: full harness gate re-run at the post-rebase head, with suites 55 and 96 called out.

  3. The off switch is currently in the ON position. The body's rollback story is "blank orgClasses and it ends within a
    day" — but orgClasses is live now. Nothing in the merge sequence separates shipping the code from turning it on: once
    a release carrying this reaches master and nodes upgrade, the daily table refresh arms enforcement on ~55 hosts
    within 24h, and the 24h settling window is the only buffer. Ask for an explicit ordering — blank orgClasses before the
    release, re-publish after a named canary set is observed on info.flux.dosStaging — or at minimum a named owner
    watching dosStaging fleet-wide on release day.


Changes I'd ask for

A. reasonToGiveUpApp, the index === 0 branch bypasses the evacuation gate (advancedWorkflows.js:3090-3100). The
block's own comment says "a node that is also evacuating must still reach the evacuation gate below", and 7afabaf
restructured the index === 1 path around exactly that (surplusDeclined carried out rather than returned). The index
=== 0 path still returns directly:

if (!runsWriter) return { giveUp: true, reason: 'SURPLUS', detail };
return { giveUp: false, reason: 'SURPLUS', detail: ...the next copy trims instead };

An evacuating residential node that is the newest copy of a surplus app and runs its g: writer stalls that app until
the surplus clears. Not destructive, self-resolving, and it would have hit the STAND_DOWN_REQUIRED path anyway — but
it's the same asymmetry the adjacent branch was just fixed for. Three lines: set surplusDeclined instead of returning.

B. The ticket collision is narrowed, not closed. 2eb3253 is right, and I confirmed the accounting sits below the
interval gate (residentialNodeDosService.js:496-517) with MAX_TICKET_GAP_MS = 2 × step = 80min against a 6h interval —
so a blocked node genuinely restarts every ticket. But two holders whose 6h blocks expire ~one step apart can still
mature inside the same 22-minute pass, and the pass is block-keyed so the fleet evaluates together. What keeps the
blast radius empty is still that every stateful 2-instance app is g: and the election refuses the primary — nothing in
the code keeps it that way. A per-node deterministic jitter folded into queueDelayMs (hash of socket address, ±½
step) closes it structurally. Fix it or say so in the body; the current text reads as if it's closed.

C. getDosStaging() doc is wrong for one case. "An EMPTY node reports HOLD with a DOS" is true for a node empty from
the start, false for one that drained to empty — observedWindowMs >= SETTLE_MS by then, so it reports EVACUATE with a
DOS. One sentence.

D. clearPlacementHold() ignores ownership while setPlacementHold(reason) takes one. One caller each today, so inert —
but a one-line owner check now is cheaper than the silent trample later.

E. Two fleet-wide changes, one rollback lever. The residential staging is disarmed by blanking a table. The static-IP
rule is code: ~1,550 slots stop claiming static and ~1,550 stop the spawner deferral, on upgrade day, revertible only
by a release. It's argued well and I wouldn't split the PR at this point — but it belongs in the release note in its
own right, not only in the PR body.


Accepted risks I'd want named in the ACK, not silently inherited

  • reinstallOldApplications still deletes before checkAppRequirements — the attrition path bypasses appEvacuationSafety
    entirely. Quantified at 2 of 9 placements across 3 synced staticip apps, one copy of three, re-synced from the
    survivors. Make-before-break drain deferred to a follow-up PR. Fine, given the number, but the follow-up should be a
    tracked commitment.
  • appEvacuationSafety returns STATELESS → safe: true before the otherHosts < 1 check, so the last copy of a stateless
    instances: 1 app is deletable. Deliberate, consistent with surplus trim, and the removal is broadcast immediately so
    the spawner rebuilds — but it is the first removal path that does this by design.
  • Unsigned artifact drives a destructive path. Bounded as argued (needs residential && !arcaneOs, DOS only on an empty
    node, self-healing), and I've now measured the bound at ~55 hosts. Signed manifests are the stated successor.

@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

Serviced. Three code changes, one doc, two harness fixes, and one correction to the body. Two of your items I'm not taking, with reasons.

Blockers

1 — the branch does not merge. Rebased onto development. 84 commits now, from 85: our suite 62 fix became empty because b69bba223 had already fixed the same race, and its own commit message said the fix didn't belong to this branch.

You were right that this needed hand-resolution rather than a merge, and for a sharper reason than the conflict count. The fdmOk work you spotted — 0b991c493 and our 8e148eb22 — collided in a way git did not report. Both set answered, on different lines, so nothing conflicted textually and the merge silently kept both. development sets it only where the body parses as success; ours set it on any 2xx. The union took the widest reading and left three answered = true where each parent has two.

That mattered. FDM replying 200 with a non-success body — our own in-band error convention, or a proxy error page — would have counted as "FDM answered, nobody is primary", and the election proceeds from there to self-promotion on evidence that says nothing. development's rule stands; getMasterIpFromFdm is now its version. One unit test was carrying the pre-migration response shape and so proved nothing about the distinction it was named after; it now uses the success shape.

2 — no gate at this head. Agreed, and outstanding. The full harness gate has not run on the post-rebase head and will, with 55 and 96 called out. I'll report the result here.

For the record the position was narrower than stated: the last full gate was 75/77 at d892c694c, not 74/74 at 7d2d004e7 — that commit isn't on this branch. Both reds were defects in those suites rather than in the code they cover, both were fixed in the two commits that followed, and both were verified individually. That still isn't a gate at this head, and the rebase has since changed suite 55 and removed three unit tests, so it needs re-running regardless.

3 — the off switch is in the ON position. Confirmed: orgClasses is live and carries 163 organisations.

Not blanking it, and I don't think the ask holds. The staging's first stage is HOLD — no new apps, nothing deleted, for a full 24h window — so the observation period you want is already in the design, and blanking the table would give a worse one, because a blank table shows nothing at all. The switch also doesn't need pre-throwing to work: if release day goes wrong, blanking then stops enforcement within a day, before anything has passed HOLD. And the table is the output of a network scan, so shipping it deliberately empty misrepresents it and costs two extra publications.

Taking the other half of your ask instead, which is the part that's load-bearing: a named person sweeping dosStaging fleet-wide on release day. ~95% of the fleet upgrades inside 4 hours, so the affected nodes enter HOLD together and reach EVACUATE together — the 24h window is a real brake but only if somebody occupies it. A runbook and a sweep exist for this, and the sweep has been run against the live fleet to prove the tooling before it matters.

Changes

A — the index === 0 branch bypasses the evacuation gate. Fixed, three lines as you said. The surplus verdict is carried out to the gate rather than returned past it, matching what 64a12759f did for the adjacent branch. A draining node that is the newest copy and runs the writer now reaches STAND_DOWN_REQUIRED, stops writing, and releases the app on the next pass once the election moves the primary. Only draining nodes change; a node that is not evacuating reports the same verdict with the same detail, which the existing test still proves. The verdict travels under NEWEST_HOLDS_WRITER so a declined trim and a held writer stay distinguishable on the decision event.

B — the ticket collision is narrowed, not closed. Agreed on the finding, and the body now says so rather than reading as closed.

Not taking the jitter. The collision doesn't come from the step being too small — it's 40 minutes against a 22-minute pass. It comes from the ticket being measured from each node's own observation start, so two holders whose starts differ by about a step mature in the same pass whatever the step is. Jitter separates such a pair by the difference of two fixed offsets, which can be zero, and moves an already-separated pair together as readily as apart. It randomises which pairs collide rather than reducing how often it happens.

The structural fix is to stop answering a fleet-wide question with a private measurement: take the turn from the shared clock the pass already runs on, so a node acts only in the pass its rank selects, and leave the observation window doing the one job it is right for — proving this node's view is trustworthy. That replaces the pacing mechanism and its whole test surface to close something nothing can currently reach, so it isn't in this PR. The body now states the residual, names what keeps it empty today, and says the guarantee therefore lives in the election rather than in the evacuation rule.

C — the getDosStaging doc. Fixed. It now distinguishes a node empty from the start, which skips the window it has no data to need and reports HOLD, from one that drained to empty, which served the whole window and reports EVACUATE.

D — clearPlacementHold ignores ownership. Fixed, but not as a one-liner, because the one-liner deadlocks.

The hold was a single slot. Two owners cannot coexist in one: a second set overwrites the first reason outright. Add an ownership check to that and the first owner can then never release — the node stays held forever for a condition that lifted. Silent unhold becomes silent deadlock.

So the slot is now a map of owner to reason, the node is held while any owner holds it, and releasing removes only your own entry. The owner is a value from a frozen enum rather than the reason text, because the reason is a message for an operator and identity by message is identity by typo; an owner the enum doesn't carry is refused at the set. getPlacementHold reports every reason rather than an arbitrary one, since the spawner logs it to say why the node isn't installing and naming one of two holds sends someone to lift a condition that won't release the node.

E — two fleet-wide changes, one rollback lever. Agreed, and it goes in the release note in its own right. The framing there will be that this corrects static-IP claims rather than tightening them: 55% of current static claims come from nodes behind a UPnP router on a private address, qualifying through a flag about the range rather than anything the node observed. Nothing evicts existing placements — checkAppStaticIpRequirements is reachable only from checkAppRequirements, which runs on install and redeploy, and there is no periodic compliance sweep — so the visible event on upgrade day is those slots ceasing to defer and becoming available for ordinary placements sooner.

Accepted risks, named

  • reinstallOldApplications deletes before it checks. Live on development today for disk, geolocation and static IP alike; this branch widens the set of nodes one of them refuses. Quantified in the body at 2 of 9 placements across the 3 synced staticip apps. The make-before-break drain is designed and lands as its own PR — reordering the check alone converts data loss into a node stuck on a stale spec forever, which is why it isn't done here.
  • The last copy of a stateless instances: 1 app is removable. Deliberate. The owner chose instances: 1; any node holding that app crashing puts them in exactly the same position, and the container is rebuilt from the specification. The refusal exists for state, and there is none to lose.
  • An unsigned artifact drives a destructive path. Stated in the body rather than inherited, with its bound — non-Arcane residential only, one app per 6 hours behind the full safety gate, DOS only on an empty node, self-healing on a corrected row — and it replaces a weaker root, not a stronger one. Signed manifests are the successor and this artifact adopts them when they land.

Found while doing this

Two harness defects, neither from your review. Suite 55 started the FDM outage four statements above the try that restores it, so a throw in between left the stub's socket closed — harmless only because that test is last in its file. And three seed builders defaulted to hardcoded ports; one of them is used twice by the same suite, so both its apps carried the same port and could never both have been placed.

The full gate is still to run. I'll post the result.

@MorningLightMountain713
MorningLightMountain713 force-pushed the feat/dos-residential-non-arcane branch from 9952b7d to 5258adb Compare August 31, 2026 08:37

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I found myself at this head

  1. The stand-down treats a failed docker stop as a successful one. advancedWorkflows.js, STAND_DOWN_REQUIRED block:

appReconciler.setControllerDesired(identifier, 'stopped', '...');
await appDockerStop(identifier);
standingDown.set(identifier, 0);

The module-level appDockerStop (line ~2218) does not throw on a stop failure — it catches internally and returns {
stopped, running, unavailable, errors }. So the catch whose comment says "a component this node failed to stop is one
it is still writing to, and marking it would make the node unelectable for a component it is running" can only fire on
an unexpected throw, and the mark is set regardless.

Failure scenario: docker refuses the stop or never becomes able to answer → stopped: false, running: [identifier] →
the node marks itself standing down anyway → masterSlaveApps continues past that identifier for up to
STAND_DOWN_PASSES_BEFORE_GIVING_UP (6 passes, ~2h) while it may still be running the g: writer. That is exactly the
state the comment says it is avoiding.

Bounded, and I want to say so plainly: setControllerDesired('stopped') also enqueues a reconcile that will stop it,
the 6-pass cap self-heals, and the next give-up pass refuses with ELECTION_UNKNOWN rather than removing anything. No
data loss. But the guard is decorative and the log line written for this case can never print. Also untested — every
test stubs dockerService.appDockerStop with resolves(). Three lines: check the verdict, continue on !stopped.

  1. Stale evidence across an address change (minor, arguably pre-existing). getNetworkClassification() looks up the
    published table on networkEvidence.ip, which is refreshed on setNodeGeolocation's 3-day cadence, while the enforcement
    tick runs every 6h. A node whose address moved between geolocation passes is classified against its previous
    address's table row for up to 3 days. It cannot start a drain inside that window (the 24h observed window and the
    ticket both restart), but the HOLD is immediate. isDataCenter()/isStaticIP() carried the same latency before this PR.
    Worth one sentence, not a change.

Things I checked and cleared

  • standingDown's owner derivation (identifier.slice(indexOf('')+1)) and isElectedPrimaryHere's endsWith(''+appName)
    are safe: appValidator restricts component names to [a-zA-Z0-9] — no underscores.
  • installErrorTtlS 3600 → 86400 is not a behaviour change: appConstants had a hardcoded 24h literal and the config key
    was dead. Same for locationTtlS, installingTtlS, sigtermExpiryS — all match the literals they replace.
  • getInterfaceIp moving the non-routable filter into the loop: sole caller is hasPublicIpOnInterface, and the
    now-redundant filter there was removed. Strictly better on an interface carrying a private primary plus a public
    secondary.
  • hasPublicIpOnInterface returning null on error: both consumers are in geolocationService and both handle three
    states.
  • fixExplorer deletion: unreferenced, not exported, no route.
  • appQueryService.installedApps / listRunningApps called with no args: both handle undefined req/res.
  • residentialNodeDosService's null-handling throughout (arcane === null, residential === null, installed === null,
    observedMs === null) all fail toward hold placement, do not drain, which is the right direction.

@MorningLightMountain713
MorningLightMountain713 force-pushed the feat/dos-residential-non-arcane branch from bc5d861 to 6ea39e2 Compare August 31, 2026 14:42
@MorningLightMountain713
MorningLightMountain713 changed the base branch from development to fix/same-ip-port-collision August 31, 2026 14:42
@MorningLightMountain713
MorningLightMountain713 force-pushed the feat/dos-residential-non-arcane branch 3 times, most recently from 6dbe7e4 to e32f375 Compare August 31, 2026 19:08
@Cabecinha84
Cabecinha84 force-pushed the feat/dos-residential-non-arcane branch from b22117e to c521dcb Compare September 1, 2026 07:25
@Cabecinha84
Cabecinha84 force-pushed the feat/dos-residential-non-arcane branch from c521dcb to 227d4e1 Compare September 1, 2026 07:30
…rovokes

Suite 60 unmounted an app volume for real and then wrote to the bare directory,
expecting EPERM from the immutable mountpoint - on the stated grounds that "the
monitor's 3s repair cycle cannot remount in between". The 3s cycle is not what
answers. The mount test does: "Volume not mounted. Continuing. Most likely false
positive." and then "Volume mounted." 144ms later. The probe then writes to a
live volume and gets exit 0, which is a green assertion about the wrong thing
when it loses and a red when it wins the other way.

Seen on a sequential run today, green on the two gates before it - which is the
signature of a race rather than a defect, and the reason it survived.

The probe now runs under `unshare -m`, in a mount table of its own. FluxOS never
sees the volume go away, so nothing repairs and there is nothing to be faster
than. The immutable attribute is on the filesystem and visible from every
namespace, so the write is refused for the reason the test is about. The
container stop went with it: the app's bind lives in its own namespace and was
never what the probe examined.

The self-heal half then needs a real unmount of its own, since the namespace
probe broke nothing. That half wants the repair to be quick, so it races nobody.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every app seedSyncthingApp builds took the same default port, and
forceNonLeader places one app on a node as its subject and the next on that
same node as its peer - so two of them met on one node holding one port and
the second install was refused, correctly, with "already used with different
application".

It only surfaced when the first install finished registering before the
second started, so suites carrying several of these passed or failed on the
gap between two installs. Suite 37 seeds four and failed this way on a gate
having passed on the one before it.

Counted rather than hashed from the name: a hash collides occasionally and
puts two apps back on one port, and a fault that appears sometimes is worse
than the one it replaced. Each suite is its own process, so the count is per
suite, and it starts clear of 31111 which suites building their own specs
still use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d185e0e)
flux#1784 adds allocateAppPort() in seed-helper.js: the same mechanism where
the builders live, covering every builder rather than this one caller. That
is the one to keep. It does not exist on this branch yet, so this cannot
simply be deleted - buildSeedableSyncthingApp still defaults to a hardcoded
port here, and removing this first reopens the fault it fixes.

Recorded at the function so whoever rebases across that merge finds the
instruction rather than reconciling two counters, or worse, keeping both:
31111+N and 31200+M stay clear only while a suite takes fewer than 89
allocations, and nothing asserts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c232318)
…he builder

#1795's funnel assigns a port inside buildSeedableApp, where a specification is
built - which is the seam with no holes, and the only one that can work at all,
because the spec is signed and hashed the moment it is built and a port added
afterwards leaves the app carrying a hash of a spec that no longer exists.

So allocateAppPort goes rather than merges, as agreed with that session. It was
the same class of fix reached one layer too high: a rule in the builders, which
four suites route around by hand-writing their compose. Two allocators counting
into one range would have stayed clear only while a suite took fewer than 89
allocations from mine, which held today and was asserted nowhere.

Suites 55 and 96 declare no port now. 96 lost `ports: [allocateAppPort()]`; 55
lost the parameter and the `ports: [port]` that used it - removing the parameter
alone would have left an identifier with no binding and failed the suite at load,
which is what checking rather than assuming caught.

The history in seed-helper's comment stays, because the funnel's own comment does
not carry it: which suites paid for this. Suite 68 hand-picked 31111..31115 to
escape it, 55 lost a run, and buildSeedableIndexRefApp pinned its second
component to a literal so suite 43 - which builds two - could never place both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven suites on the bc5d861 gate - 36, 45, 46, 47, 48, 51 and 53 - died on
`"after all" hook ... Timeout of 30000ms exceeded` with every one of their tests
green. The hook is env.teardown(), and 45, 46 and 53 call nothing else in it.

Which of its steps took the thirty seconds was not recorded anywhere. Every step
is a docker round trip with no timeout of its own - the exec sweep across every
node, N sequential container stops, N volume removes each able to fall back to
booting a helper container, and a network remove that retries while endpoints
detach - and they printed only on THROW. Being slow is not throwing. The
per-container dump cannot cover it either: log-on-failure dumps on a TEST
failing, and its after-all backstop needs nothing to have passed.

So each step reports as it COMPLETES, and the step whose line is missing is the
one that ran out. The volume line counts EPERM helper-container fallbacks
separately, because that branch boots a container per volume and is the
difference between a two-second phase and a long one.

Diagnostic only: nothing here changes what teardown does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The veto test timed out after 60s on residential:decided with source node-veto,
and the decision it wanted could not be reached rather than having gone the wrong
way: node 5 logged 24 consecutive ticks of "no network verdict to act on yet",
every published decision carrying classification:null and source:null.

getNetworkClassification returns null at three gates - no evidence, no
contradiction signals, or a published table that was never consulted. The test
waited for the first and its own assertion on evidenceAgainst covers the second.
Nothing waited for the third, and the node's log carries no ipLocationStore line
at all, so the store never installed or adopted a baseline and
publishedClassification was answering consulted:false throughout.

The suite already half-knew this - test 1's comment says the verdict "also needs
the published table, which arrives later" and works around it by asserting on
evidence alone. Waited for on every node now, through the same waitForLocationTable
that 64 and 66 use, because the table is the authority the whole suite runs on and
bootToReady has already started the ticker by then.

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

This branch introduced its own allocateAppPort and later deleted it in favour of
the one in the builder. Stacking onto #1795 replayed both, and resolving the
intermediate conflicts kept the call sites alive so the history stayed internally
consistent - but the deletion patch only matched the sites its own pre-image
named, so ten calls survived a function that no longer exists.

Nothing catches that by reading: `node --check` parses an unbound identifier
inside a function without complaint, and the suites that call these builders fail
at load with a ReferenceError naming neither a port nor this branch. eslint
no-undef over test-infra/ finds all ten in one pass, and is worth running before
any gate.

seed-helper.js is now byte-identical to #1795's: the entire delta was those ten
references, so this branch contributes nothing else to that file.

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

reconciler-suite carried nextSeededPort/portForSeededApp with a docstring saying
to delete both once the builder-level allocator landed, because that one covers
every builder rather than this single caller. #1795's assignPorts is that
allocator and it is now underneath this branch, so this is when.

Leaving it was not merely redundant. Its counter starts at 31200 - the same
ALLOCATION_BASE the allocator hands out from - so the two produce the same
numbers, and a port it supplies arrives at assignPorts as a hand-picked port
inside the allocator's reserved range: refused outright with "asks for port
31201, which is inside the range the harness hands out". Every suite seeding a
syncthing app goes through here.

It survived the stacking rebase because the cherry-pick that removed it was
skipped as a duplicate of #1795's original, while this branch's own commit
adding it replayed. No caller passes a port, so the signature loses the
parameter with nothing left referring to it.

seedSyncthingApp is now identical to #1795's; the only thing this branch still
changes in this file is placeGAppInOrder waiting for each holder to register.

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

Stacking onto #1795 put this branch on a syncthingService whose getConfig takes
no request and answers with the config itself. findSyncedPeer still called it as
getConfig({}, null) and checked configResponse.status, so it returned early on
every invocation and answered null without ever asking a device. Nothing in the
rebase objected - both spellings parse, and the call was not in any conflict.

Null is the answer that KEEPS an app, so this fails safe; but it means a suite
asserting the give-up path can never see it, and the six unit tests that caught
it are the only thing between that and a silent behavioural revert.

Its sibling checkIfPeersAreSynced twenty lines up already called getConfig the
new way, which is what made the pair inconsistent rather than merely old.

The tests in "findSyncedPeer excludes this node" stubbed the same two calls with
{status, data} envelopes. One of them asserted that findSyncedPeer returns null
- which the envelope produced on its own, from the early return - so it passed
with the self-exclusion it exists to prove DELETED. Verified by deleting it:
2 failing before the stubs were corrected, 0 after, 95 passing.

The dosStaging test stubbed listRunningApps and appsResources with placeholder
strings. The projection those feed now reads them, so result.data came back
undefined and the failure named the projection rather than the field. Given the
fixtures the neighbouring test already defines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… probe's own call

Test 2 spent its whole ten-minute budget on a trim that had already been
refused, for the right reason, in the one pass it gets.

Two faults, and the second is why the first was invisible.

The partition from test 1 was never really healed. healPartition drops the
iptables rules and says in its own comment that the caller re-runs discovery so
the dead cross-group sockets get re-dialled; 68, 70 and 71 all do that and this
suite was the only one that did not. Node .12 logged nothing but timed-out
handshakes to .10 from the heal onwards, and .10 logged the same back.

The precondition then waited for the wrong fact. It polled the two nodes' PEER
LISTS until each could see the other, but the trim reads peerComponentState,
which GETs /apps/heldcomponents on the newest copy. Those are different facts:
the peer map still carried the entry from before the partition while nothing
could actually talk, so the wait passed immediately and the single probe found
the newest silent. It now makes that exact call, from the node that will make
it, and requires the writer in the answer.

The single pass is what makes a merely-usually-true precondition a coin toss
here: the give-up pass runs on `blockHeight % (removeFluxAppsPeriod *
speedMultiplier) === 0` - 44 blocks - and this suite sets no override, so the
53 blocks driveUntil spends buy one attempt and no second.

The rule under test never misbehaved: WRITER_UNCONFIRMED on an unreachable peer
is the fail-towards-no-trim this design is built to do.

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

The per-step timing this branch added named the step on its first run. Across the
seven suites that lost the previous gate to an `after all` timeout with every one
of their tests green:

  suite 36  stops 6806ms of 7167ms      suite 45  stops 13865ms of 14427ms
  suite 46  stops 9436ms of 10998ms     suite 47  stops 3607ms of 3814ms
  suite 48  stops 5641ms of 5950ms      suite 51  stops 7966ms of 8327ms
  suite 53  stops 3710ms of 3894ms

Everything else is milliseconds: the immutable sweep 59-558ms, the database
0-1ms, the volumes 35-900ms, the network 72-125ms. The EPERM helper-container
fallback fired once across all seven. resetSyncState does not appear at all -
the totals are the sum of the steps, so it was never the cost.

Sixteen containers stopped one at a time at ~200-870ms each is work that scales
with the fleet, under an `after` hook budget that is a flat 30s in 123 of 137
suites. 14.4s against 30s on a box running nothing else is a factor of two, which
is why they pass on a quiet box and why they did not on a loaded one. The serial
loop bought no politeness either: the docker daemon serialises stops across every
concurrent suite regardless.

The one ordering that means anything is kept - every node goes down before any
infra does, so a node is never left running against a stopped mongo writing
errors into the dump that read as product faults. infraContainers is the
discriminator and carries all six (mongo, daemon, syncthing, external, fdm,
registry); within each group nothing depends on order.

NOT YET VALIDATED: the gate running as this lands is on the previous code, and a
teardown change touches every suite. It needs its own gate.

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

Suite 55's test 16 drove its whole 198-second budget and never saw
STAND_DOWN_REQUIRED. Not a wrong answer - no answer. The node was the elected
primary, was enforcing correctly (119 ticks of residential=true arcaneOs=false
enforce=true), and evaluated primaryapp twenty times: 10 DEPARTURE_INTERVAL and
10 AWAITING_TURN. It never reached the stand-down check because it never got
past the pacing gates that precede it.

It was holding SEVEN apps by this point - clockapp, firstout, residentapp,
secondout, sharedapp, worldapp and primaryapp - collected from the fifteen tests
ahead of it. Evacuation gives up one app per departure interval, and a node
inside that interval records nothing against any other app's queue ticket, so
each departure restarts the turn of everything still held. It shed six inside
the window and ran out one departure short of the seventh.

DEPARTURE_WAIT_MS is two cycles. That was right when this test was written and
stopped being right as tests were added in front of it, with nothing to say so:
the budget is a constant and the cost scales with what the node accumulated.

Now derived from the node's own localAppCount at the moment the drive starts,
which is the same rule the rest of this suite already follows - every wait that
must contain a departure is derived, not typed. Reading it from the node rather
than counting by hand here means adding a sixteenth test ahead of this one
cannot quietly shorten its budget again. One cycle of headroom so the final
verdict has a pass to land in.

The other seven DEPARTURE_WAIT_MS sites are unchanged: they run earlier, where
the node holds few enough apps for two cycles to cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every app this suite seeds asks for five instances on a five-node fleet, so each
lands on every node, and nothing removes them between tests. By the end of the
first describe node 1 holds seven apps. Evacuation gives up ONE app per departure
interval, and a node inside that interval records nothing against any other app's
queue ticket, so every departure restarts the turn of everything still held.

The stand-down tests were therefore queued behind six unrelated apps at roughly a
hundred seconds each. On the 2026-08-31 gate the node shed six inside the budget
and ran out one departure short of the seventh: primaryapp drew ten
DEPARTURE_INTERVAL and ten AWAITING_TURN verdicts and never reached the
stand-down check at all - no answer rather than a wrong one - and tests 17 and 18
never ran.

dc94eb4 budgeted for that queue by deriving the wait from the node's app count.
That was the wrong fix and this supersedes it: it made one test wait thirteen
minutes, and it left the next person to add a test to rediscover the same thing.
These tests need one node that is the elected primary of one app and is draining.
A second fleet costs one boot and gives them exactly that, and the budget goes
back to two cycles because two cycles is now true.

The fleet setup becomes bootResidentialFleet() rather than being duplicated, and
the four node constants move to module scope with it. The moved tests already
seed what they use - primaryapp and lockedapp - and return their nodes to service
first, so nothing carried over from the describe above them.

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

#1781 creates the harness network Internal, so docker gives node containers no
default route. FluxOS decides whether a node holds a fixed public address by
looking for one - hasPublicIpOnInterface reads /proc/net/route and returns false
when there is no default route at all - so on the stacked tree EVERY node reads
DYNAMIC. Verified across both gates: pre-stack every node logged "public IP on
interface: true", and on this gate all 65 dumped nodes logged false, none true.

That took suite 21's two static_ip deferrals with it. The node can no longer be
STATIC, so the deferral the suite waits for cannot fire, and it is skipped for
`datacenter` instead - six of ten tests failing on that one fact.

Neither side was wrong. The isolation is the point of #1781, and a node with no
route out is not a static endpoint. What was wrong is that the property was a
side effect of the wiring rather than something a suite declares.

So it is declared, the way geolocation and dataCenter already are, and
provisioned as the REAL thing rather than a flag the product honours: the
entrypoint installs a default route via the network's own gateway before FluxOS
starts. It restores the fact without restoring connectivity - an internal
network's gateway forwards nothing outward - and its absence is how a suite asks
for a NAT'd node, which is what every node is today.

Adding it for everyone would have been the same mistake pointing the other way:
every node STATIC, no way to be DYNAMIC, and the topology still deciding.

seedMongo's staticIp is driven by the same declaration. It is not dead - it is
what a node answers during boot, before its first lookup completes and
setNodeGeolocation recomputes - but it was seeded true unconditionally while the
recompute said DYNAMIC, so it looked like a control and was silently overwritten.

db-client's seedGeolocation and dropAndReseed are deleted: dropAndReseed had no
callers anywhere in test-infra, and it was seedGeolocation's only one.

The route failure is loud rather than `|| true`. A swallowed failure here reads
exactly like a node that was never asked for a route, and the suite then fails
somewhere else on a classification it cannot explain.

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

A restore stops the component to swap what is underneath it, and appendRestoreTask
refuses when it cannot - "could not be stopped, so its data cannot be replaced
safely". The suite issued the restore without ever establishing that the app was
settled, and on the 2026-08-31 gate the syncthing leader election named a leader
and requested the start 11ms before the restore arrived. The stop met a start
already in flight, the refusal came back as a 200 whose body was still "Pausing
syncthing folder", and the assertion for /Finalizing/ failed on it.

Test one waits for the two syncthing DAEMONS to find each other. That is a
different fact from this app's own election having finished, and it is the second
one the restore depends on.

The race was always there. It could not surface while findSyncedPeer returned
null without asking a single device - the transition never reached the leader
start this early. Repairing that function is what let the election run, which is
the second suite this week where fixing a silent always-null unmasked a
precondition nobody had needed to state.

Waited on the container's STATE rather than a `started` actuation: this suite's
own before() installs the app, so an unanchored wait for that event is satisfied
by the install rather than by the leader start.

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

Same arithmetic as the stand-down split, one test earlier. Both of these need a
departure to actually happen, and a node sheds ONE app per departure interval
while a node inside that interval accrues nothing towards any other app's turn -
so on the shared fleet they queue behind whatever the thirteen tests above them
left on node 1, against a DEPARTURE_WAIT_MS of two cycles.

On the 4fc95ac gate that was exactly enough to fail. Node 1 held clockapp and
sharedapp - only two, not the seven that killed the stand-down test - and
sharedapp drew eight AWAITING_TURN while node 3 drew four DEPARTURE_INTERVAL.
Neither node ever departed, so the app never went short, so the
BELOW_INSTANCE_COUNT refusal the test waits for on the OTHER node could not be
published. One departure interval plus one full ticket against two cycles is no
headroom, which is why it passed until something in the tree moved.

Splitting rather than widening the budget, for the reason the stand-down split
was preferred: a budget sized for the queue leaves the queue there for whoever
adds the next test. This is the second time that queue has bitten in one day, at
two different tests, which is the argument for removing it rather than measuring
it.

Three fleets now: 13 tests, 2, and 3. The accumulation still exists in the first
describe and is still harmless there - nothing in tests 1 to 13 needs a departure
to complete inside a fixed budget.

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

holdOutPendingNode refuses a node that has not started yet with a one-directional
INPUT drop on each running node. Its comment claims "one direction suffices
because a connection needs both" and nothing checked it. Suite 71 depends on it -
it moves a node's boot out of a 63 second acceptance window - but covers it only
by going red for its own reasons when it fails.

Asserted on both sides. A drop on INPUT stops the held node's SYN arriving; a
running node dialling out still sends one and it is the reply that is dropped. So
neither direction can complete, and testing only the outbound half would pass on
a pair that connected the other way round - the mistake suite 96 made.

The control is the part that makes the rest evidence. An empty peer map satisfies
"the held node is absent" exactly as well as a working drop does, so the suite
waits until the running nodes have peered WITH EACH OTHER before asserting the
held one is missing: absence measured in a fleet where presence is demonstrable,
at the same moment. Then the drop is removed and the same node joins, which is
what attributes the absence to the drop rather than to a node that was never
going to peer.

releasePendingNode is the counterpart the primitive did not have - a suite could
hold a node out and had no supported way to let it back in. Best-effort per rule,
like healPartition, which is the same shape one function down.

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

spawner:noCandidates was published on every pass that placed nothing - roughly
every 240ms per node, and true continuously for any fleet whose apps are all at
their instance count. fluxEventBus states the rule it broke at the top of the
file: FACTS AS EVENTS, CADENCE AS A COUNTER. Subscribing to it shrank the
Last-Event-ID replay window from most of a suite to about four minutes, so the
client stopped subscribing - leaving product code publishing an event nothing
could receive, and a waitForNoCandidates helper no suite called. An event nobody
can afford to listen to is not a telemetry surface.

So the cadence is now a counter, and the fact it was standing in for is an event:
spawner:candidacy, published only when this node's verdict on an app CHANGES -
carrying the app and the filter that removed it, or 'candidate'. The survivor
breakdown stays in the log line, which is where it was useful today.

This is what suite 78's last test needed. It asserted that a node which stood
down is one of the two holding the app afterwards - but that is a draw among
every eligible node, and the design promises nothing about who wins. Measured on
an idle box with the claim rows dumped: only THREE of five non-holders stood
down, the other two never claimed at all because the app already read as covered
when they looked, and after the withdrawal all five are candidates. A stood-down
node wins three times in five, and the suite has been passing on that.

Eligibility is a fact about one node, and afterAlreadyHeldOrTried is the filter
that would hold a stood-down node out - so the test now waits for any node that
stood down to report that verdict flipping back to candidate, and is indifferent
to which one wins.

The instrument that found this also killed the theory it was written for: every
claim list is empty after the withdrawal and identical before it, so nothing was
stale and nothing double counted.

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

Every node in stoodDown was a candidate for this app before it claimed - that is
how it came to stand down - so an unanchored waitForEvent is answered from the
buffer by that earlier verdict and passes before the rival has withdrawn
anything.

Proven, not reasoned: with a mutant that permanently excludes any node which ever
withdrew, the unanchored form still passed 6/6. Anchored, the same mutant fails
test 6 and leaves 1-5 green.

Two attempts were needed to get that red, and the first failure was informative
in its own right. Setting trySpawningGlobalAppCache on withdrawal did not exclude
anything, because the decline path deletes that entry on the next pass - the
spawner fix two commits down was repairing the damage as fast as the mutation
made it. A mutant that stays green means either the test is blind or the
mutation is not real, and those need opposite responses.

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

Suite 02 lost the f8fe3db gate with all eleven of its tests green: the
`after all` hook died on its flat 30s budget with `db 32831ms`, against the
other 145 teardowns in that same gate at 0-2ms. Perfectly bimodal, so it is a
hang and not slow work.

mongodb 7.5.0's `_close()` ends its pooled sessions with `endSessions`, a w:0
write that needs a connection but never a reply, and it skips that call ONLY
when server selection over its CACHED topology description comes back empty.
Teardown stopped mongo and then closed the client, which makes that cache a
coin flip: usually the monitor has already marked the server Unknown and the
call is skipped, but when it has not, close() commits to an operation whose
server goes Unknown mid-flight and waits out the full 30s
serverSelectionTimeoutMS. `squashError` swallows the error, so nothing is
logged and the suite just loses its budget.

Instrumented on chud, six suite-02 runs at a time so the stops land together.
The in-flight snapshot shows the sequence: `Standalone` on entry to close(),
an implicit session appearing as `endSessions` commits, `Unknown` two seconds
later, then 29 seconds of nothing.

  closing after the stop   27 of 30 runs slow, ~33s each
  closing before the stop   0 of 30 runs slow, slowest 3ms

Closing while mongo is still up removes the precondition rather than budgeting
for it. The nodes are already stopped by that point, so nothing is writing.

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

`trap 'kill $CAP1 $CAP2 $CAP3'` reaches three subshells and nothing inside
them. For CAP1 the children are `sudo` and the `dmesg` it execs, which are
root-owned, so they survive an unprivileged kill of their parent, reparent to
init, and leave `grep` blocked forever on a pipe nobody will close. Three
processes per gate. Because they stay in the ssh session's cgroup the scope
goes `abandoned` and logind never reaps the session, which is why `uptime`
reported 54 users on an idle chud while `who` reported none.

Documented as leaking since 2026-08-07 and hand-cleared at least three times
without the cause being fixed. Found this session: 66 runs' worth on chud and
31 on cindy, the oldest dating from that box's boot. `docker events` was
orphaned alongside them, so CAP3 leaks too and the fix cannot stop at CAP1.

CAP1 now records the PID of its root half so the trap can signal it directly -
kill `dmesg` and `grep` sees EOF and the subshell follows it out - and the
trap takes each sidecar's children before the sidecar itself, since killing
the parent first loses the ppid link that finds them.

Proven both ways on cindy with the sidecars launched exactly as the gate
launches them: the current form leaves 3 survivors, this one leaves 0.

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

9fcabdc taught bootAndPeer that a deferred node is a hole in the ring exactly
like a stub, and stopped one function short. derivePeerThresholds is called with
the stub count alone, so a fleet with a deferred node derives thresholds for more
dialers than exist - asking each node for a peer that cannot answer until
startNode creates it.

No suite moves, which is why it went unnoticed. The arc is a step function and
every current fleet with a deferred node sits away from a step: 10/1, 11/1 and
12/2 stay at 4, 6/1 stays at 2, 3/1 stays at 1. Checked by enumeration across all
five rather than assumed. 6/2 and 7/1 are the nearest shapes that change, so this
was a trap set for the next suite written rather than a fault in an existing one.

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

checkLoggedUser had two outcomes sharing one envelope. Not authorised answered
createErrorMessage(PRIVILEGE_RESPONSE.NONE), so data.message was 'none'. The
catch answered createErrorMessage(error.message, ...), so data.message was
whatever was thrown.

data.message is not free text on this endpoint. PRIVILEGE_RESPONSE says so at
the top of the file: the frontend branches on those four strings, in a
separately deployed repo, reading them out of the body whether the status says
success or error. fluxos-frontend's router guard does exactly that, and clears
localStorage.zelidauth on exactly 'none':

  const privilege = response?.data?.data?.message || 'none'
  fluxStore.setPrivilege(privilege)
  if (privilege === 'none') { localStorage.removeItem('zelidauth') ... }

So a thrown error set the privilege to a string like 'No user Flux ID
specificed', which is not 'none', and the stale session stayed in localStorage
instead of being cleared. Access still failed closed - no route's privilege list
contains an error string, so every guarded route bounced to '/' - but the user
was never logged out, and each later navigation repeated the same failure
against the same bad credentials.

The three input throws are not the reachable trigger: the guard only calls with
all three fields present. What reaches the catch is verifyPrivilege itself
failing, which is exactly when the session should end.

message now carries the contract on both paths. The error is not lost - it is
still logged, and name/code still separate a failure from a plain refusal, which
sends neither. The status stays 200, per the rule stated in messageHelper: a
request that reached a handler is answered in the body.

Every other consumer of /id/checkprivilege was checked and none is affected:
cloud_admin reads message only when status is success and validates it against
its enum, fiat-orchestration-backend and FluxDrive test only status, the
palworld and minecraft sites define a caller nothing calls, and flux-mcp only
lists the path. The frontend guard is the only reader of message on the error
path, and it is the one this fixes.

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

setNodeGeolocation reschedules itself three ways - 10s while no IP is detected,
3 days on success, 5 minutes on failure - and kept no handle for any of them.
It has two callers: serviceManager at boot, and fluxNetworkHelper on EVERY IP
change. So a second caller did not resume the loop, it started another one, and
nothing could stop either.

A node that changed IP three times therefore ran four independent geolocation
loops for the life of the process, each calling the geolocation API and writing
the same record on its own schedule. On a node that never detects its IP, each
of them also logs an error every ten seconds, so the log noise multiplied with
every IP change. Nothing short of restarting FluxOS ended any of it.

Ownership is the fix, not a guard at the call sites: the loop is a singleton the
module owns. One handle, cancelled before it is re-armed, so a second caller
resumes the one chain. One pass at a time, because an IP change arriving while
the previous pass is still awaiting the geolocation API would otherwise let two
passes interleave their writes and settle lastIpChangeDate on whichever finished
last rather than on the order the addresses changed - the late caller is held
and run afterwards, never dropped. And stopNodeGeolocation is exported, because
a timer nobody can stop is a leak wherever it runs.

Proven red first, all three: two callers woke two passes three days later, the
loop could not be stopped at all, and two callers ran concurrently. 61 passing
in this file with the fix, 6,130 in the unit suite with 0 failing.

The test file faked setTimeout but not clearTimeout, which left the real
clearTimeout unable to cancel a fake id - a cancellation would have silently
done nothing and every cancelled timer still fired. Faked together now. Date is
still deliberately not faked; the service reads it for the IP-change window.

Diagnosed in fluxModels TIMERS_NOBODY_OWNS.md, which measured this from the
other side: the same unowned timers are what made the unit suite fail at random,
because a timer armed in one test fires inside a later one and writes to a
logger three files' worth of exact log.error assertions are counting.

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

Suite 96 test 2 was the single red on the b22117e gate, and the product was
right: the second-newest copy reported SURPLUS/WRITER_UNCONFIRMED and trimmed
nothing, because peerComponentState's 10s probe found the newest copy silent.
The rule is "never trim while you cannot confirm the newest holds the writer",
and it held.

The test lost because it gets one attempt. The give-up pass runs every
removeFluxAppsPeriod * 4 = 44 blocks and the drive spends about 53, so there is
exactly one. The precondition was checked up to three minutes before that pass
landed - the newest answered the test's identical curl and had stopped
answering by the time the product asked. A precondition that is merely usually
true decides nothing here, as the file's own comment already warned.

driveUntil evaluates its condition before every block, so the condition is
where the guarantee belongs: it now waits for the newest copy to be answering
before another block is driven. The chain cannot advance into a give-up pass
through a window in which that pass would have to refuse. No budget is widened
and the pass rate is untouched - driving longer would have reached block 88 and
bought a second coin toss rather than removing the coin.

Measured on cindy, three concurrent copies of the suite: 3 of 3 green. The same
rig failed 2 of 3 on the attempt described below.

The attempt not taken, recorded because it looks obviously right and is not.
Compressing removeFluxAppsPeriod to 2 the way suite 55 does gives the drive
many passes and halves the runtime - and it breaks the fixture. A pass then
fires within ~7s of the stub announcing the fourth copy and trims the newest
real one before the setup's wait can observe four holders, because the stub
announces runningSince - 24h and is therefore the OLDEST, so the surplus rule
correctly picks a real node. stopTicker does not save it: blocks already in
flight are still processed. THIS SUITE ONLY SURVIVES ITS OWN SETUP BECAUSE THE
GIVE-UP PASS IS RARE - the knob that would make it fast is the knob protecting
its fixture, and making it fast means the suite owning the clock from before
the surplus exists, which is a redesign of the setup rather than a config
change.

The fixture's failure now names which holder is missing rather than how many
there are. Three can mean the stub was never counted or that a real copy was
trimmed early, they need opposite fixes, and the count alone cannot tell them
apart - it cost most of a session to learn that once.

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

Found by Cabecinha84 in review. The STAND_DOWN_REQUIRED block awaited
appDockerStop and then marked the component regardless of what it answered.
The catch beside it says why that is wrong - "a component this node failed to
stop is one it is still writing to, and marking it would make the node
unelectable for a component it is running" - but it can never fire for that
reason: appDockerStop REPORTS a refusal rather than throwing one. It catches
internally and returns { stopped, running, unavailable, errors }, where
`stopped` is read back from docker rather than taken from the stop call.

So on a node where docker simply refuses, or never becomes able to answer, the
result is stopped:false with no throw, and the component was marked anyway. It
is then excluded from the primary election - standingDown.has() is checked in
masterSlaveApps - for up to STAND_DOWN_PASSES_BEFORE_GIVING_UP passes while it
may still be running the g: writer. Exactly the state the comment describes.

Bounded, and worth stating: setControllerDesired('stopped') also enqueues a
reconcile that will stop it, the cap self-heals, and the next give-up pass
refuses with ELECTION_UNKNOWN rather than removing anything. No data loss. The
guard was decorative and its log line could not print.

The verdict is read now. Marking happens only on stopped:true; anything else
logs what docker actually said - which components are still running, whether it
was unavailable, and the errors - and leaves the component unmarked so the next
pass retries.

Proven red first, in the shape a live node sees. The existing test for this
rejects the LOW-LEVEL dockerService.appDockerStop, which the wrapper catches, so
it was passing on an unexpected throw from dockerActual rather than on the
mechanism it names. The new test lets the stop return and has docker answer
cleanly that the container is still up: no throw, stopped:false. It failed
before this change and passes after, and the two cap tests either side of it
still pass, so the success path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Cabecinha84
Cabecinha84 force-pushed the feat/dos-residential-non-arcane branch from cba4798 to 9a12057 Compare September 3, 2026 08:26
@Cabecinha84
Cabecinha84 merged commit cf2ffa3 into development Sep 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants