Skip to content

Release 8.18.0: app ownership and tenant isolation, placement diversity, and the first gated release - #1799

Merged
Cabecinha84 merged 922 commits into
masterfrom
development
Sep 8, 2026
Merged

Release 8.18.0: app ownership and tenant isolation, placement diversity, and the first gated release#1799
Cabecinha84 merged 922 commits into
masterfrom
development

Conversation

@Cabecinha84

@Cabecinha84 Cabecinha84 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Release PR, developmentmaster. 20 merged PRs since v8.17.1, 470 files, +89k/-17k, 57 new test files.

Cut following RELEASING.md — the process introduced by #1788 in this very release, so this is the first PR the gate judges.

release step state
master merged back into development (13e92bf2a) done — the v8.17.1 fingerprint and the tampering blocklist entries that landed straight on master
the merged ZelBack is the PR head's ZelBack yes — master's two extra commits are both under helpers/, outside the hashed tree
package.json 8.17.1 → 8.18.0 (0b7d1a390) done
helpers/hashes.json carries f91c1a1ec2604642ddd69edc121d18b6 done — the md5 over ./ZelBack at this tip
the fingerprint is in the signed document central serves verified — hashes.runonflux.io/hashlist, seq 41, valid Ed25519 signature
npm run lint clean
CI on this PR gate, build (ubuntu-22.04, 20.x, 7.0), build, deploy, publish all green; GitGuardian red — see below

GitGuardian is red on a known false positive, and it needs a maintainer dismissal rather than a code change. The flagged string is the WIF test key 5JTeg79... in tests/unit/fluxCommunicationMessagesSender.test.js; #1795 added one more occurrence of a constant that already appears 20+ times in development. It is the same red that stood on #1795 itself. Every other release PR since 8.15.0 passed this check, so it is new with this content, not with this PR.

⚠️ Before merging: one cross-repo dependency is still open

flux-domain-manager#154 must merge and be deployed to every FDM server first. #1777 in this release retires apppause, and a paused container was the only reason an operator-stopped g: master kept its load-balancer primary. FDM#154 replaces that with an explicit GET /apps/heldcomponents probe — an endpoint this release adds. Cutting the release before FDM has it means an operator-stopped g: app loses its primary and can come back on a different instance's copy of the shared volume.

The other coordinated repositories are already in: fluxos-frontend#140 (monitoring controls) and fluxos-frontend#151 (encrypted specifications) merged 2026-08-20 and 2026-09-03.


Ownership, authorisation and tenant isolation

#1787 — an app owner is validated as an identity a signature can verify against. owner was only ever checked for being a string. A signed update setting it to a username or a mistyped address did not transfer the app, it destroyed it: no key on earth signs for "TrippleCore", so nobody — not the former owner, not FluxOS, not us — could update, renew, restart or reassign it again. It happened on 2026-08-14 to a customer app with 18 days of paid life left, and was recoverable only by accident.

#1791 — hosting an app is not owning it. The node operator is admitted to no app-scoped endpoint. appownerabove is removed from the tree entirely, dispatch case and verifier both, so the string authorises nobody rather than waiting to be reached for again. A specification decrypts for its owner alone. Underneath, the check that decides who is asking is rebuilt: a privilege is a named member rather than a spelled string, and the check takes the identity it is judging rather than the request it arrived in.

#1778 — every write to a tenant volume moves into a throwaway container, with that volume as the only thing mounted, including createfolder, renameobject, removeobject and upload. It closes a remotely-triggerable root compromise (the boot sweep fed a tenant-writable file's contents to a root mv destination — sudo mv -T <volume>/.flux-old-pwn /etc/cron.d/pwn on the next restart), an app owner's ability to take a node down by opening their own file manager (symlink-following folder measurement, unbounded, as the FluxOS process), and two download endpoints that left every downloaded file world-writable.

#1796 — the support team is a list of Flux IDs, not one. fluxSupportTeamFluxID and fluxapps.teamSupportAddress both name lists; four privilege checks that compared with === now match against the set.

#1795 — two Flux nodes on one public address could install onto the same port and nothing refused it. Four nodes behind one home router is ordinary. Each binds its own host port successfully, the router forwards to exactly one, and every other node's app is unreachable from the moment it starts while being broadcast as live. The port test now asks the peer to bring back what the port actually said and checks it against a secret only this node could have put there, and the other Flux nodes at the address are asked which ports they already hold.

Data safety

#1779 — restore acquires before destroying. On 2026-08-04 a customer's Palworld world was destroyed by a restore: one unrepairable volume jammed a node-wide gate in the syncthing monitor, so no folder was ever configured for any app on that node, and the restore deleted appdata before fetching anything and then hard-redeployed every other instance — rm -rf on the 35 GB volume of the one node that held the world. Five product fixes and six new harness suites, one of which boots a real syncthing daemon.

#1794 — a missing container must not crash FluxOS, a failed redeploy must not uninstall the app, and a log poll must lose nothing. Two live-node failures three hours apart from the same line, five more found in the same paths. The log endpoint is rebuilt: it answered every poll on a 1500 ms timer, lost lines written between polls, and made docker re-read the whole log to do it. Additive — a client that knows nothing about it gets the old behaviour exactly.

#1780 — the reconciler is the only actuator for every operator command, and the crash-recovery ladder paces faults rather than operators. A customer restarting their Palworld server six times in twenty-one minutes reached the fifteen-minute rung, which from outside is indistinguishable from the app refusing to come back.

Placement, DOS and the fleet's view of itself

#1774 — placement diversity. Fault domains are provider organisations from a published IP table rather than /16 arithmetic, so Hetzner's twelve Finnish /16s count as one domain and a /16 spanning ten countries stops cross-blocking. Synced apps reach their full instance count instead of stranding (a 3-instance WordPress sat at 1 of 3, returning 503, permanently). A provably unsatisfiable spec is refused at registration, before payment. A dead node no longer holds a synced app down for the 125-minute broadcast expiry. Nine g: primary-election defects fixed. Network policy moves out of this repository into RunOnFlux/fluxos-network-policy — blocked and vetted repositories, enterprise nodes, the tampering blocklist and the IP location table — so merging application code no longer carries the power to change network policy.

#1784 — a residential node holds, evacuates, and only then goes to DOS. DOS >= 100 is not a mark: it deletes every app directory and volume on the node. Staging means the destructive step is only ever reached on a node holding nothing. Enforcement reads a RESIDENTIAL verdict from the published table, so it can be ended fleet-wide within a day without a release.

#1797 — a node answers a state-sync request for itself. A node that has just started answered with whatever it held, and an empty answer looked identical to a complete survey, so a node could finish its sync, mark itself ready and start placing apps having learned nothing. A response now carries refused when the responder is not authoritative, and every outstanding request has its own clock.

#1786 — a node it has not heard of yet is not a node that is absent. Three networkStateManager lookups awaited the indexing lock, which is free before the first fetch has ever run, so every node in the fleet came back absent and nothing distinguished that from not being a node.

#1783 — a peer we just heard from is alive, whatever it sent. Liveness was credited only by a pong, an ordinary frame queued behind everything else that peer sent, so the heartbeat fired first on the connections with the most evidence of being alive.

Memory and monitoring

#1775 — bound the sync path and take heavy dependencies off the boot path. One sync response cost 676 MB of RSS in two seconds, measured on a live node. Separately, the verification pool started os.cpus().length - 1 worker isolates at boot and never stopped them — fifteen V8 heaps on a 16-core node, growing for the life of the process, to serve 18 batches in 10.5 hours.

#1777 — one store of extracted samples, dead endpoints fixed, and the on/off and pause controls retired: /apps/startmonitoring, /apps/stopmonitoring and the stats stream answer 410 naming what to poll instead, rather than an anonymous 404. The routes go at the next major version.

Release process and CI

One note on the merge-back: from this release the tampering blocklist is read from fluxos-network-policy (config.policy.baseUrl), not from helpers/tamperingblockednodes.json. The entries bea9e1bdf added to master are already present in the policy repository — both lists hold the same 14 — so nothing is lost by the file becoming inert, and future additions go to the policy repository.

#1789 — hash publication becomes a dispatch. This repository's CI loses write access to fluxhashes; a contents-write PAT exposed to every branch build is gone, and the org-scoped classic token it used was revoked.

#1788 — release PRs are gated on the signed hash list, and the tag is cut after merge. A release cannot merge until the tree it ships is already in the signed document; the tag and GitHub Release become CI outputs. RELEASING.md is the process doc from here on.

#1781 — the integration fleet installs from itself and reaches nothing outside. ~1.2 million packets left the box per gate run, to five third parties. The fleet now runs with its network sealed and tcpdump reporting 0 packets received by filter — and four production fixes the work turned up.

#1782 — the tree lints clean, 45 pre-existing errors fixed by hand rather than with --fix, separated out so it cannot hide a review of anything else.

API surface

New:

GET    /apps/placementlocations          POST /apps/placementfeasibility
POST   /apps/moveobject                  POST /apps/copyobject
POST   /apps/compressobject              POST /apps/extractobject
GET    /apps/operations/:jobId           DELETE /apps/operations/:jobId
GET    /apps/fileoperationimage/:imageid GET  /apps/promotedfolders
GET    /apps/heldcomponents              GET  /apps/appcomponentnames/:appname?
GET    /apps/appkill/:appname?
GET    /flux/currentbranch               GET  /flux/currentcommitid
GET    /flux/rebuildui                   GET  /flux/testcounters
POST   /flux/portsinuse

Removed: GET /flux/rebuildhome. Deprecated behind 410: /apps/startmonitoring, /apps/stopmonitoring, the stats stream.

Behaviour changes a caller can be broken by on release day:

Known and open, not fixed by this release

31 /syncthing GET routes are unauthenticated and cached, including /syncthing/db/browse/:folder and /syncthing/db/file, which hand any anonymous caller a listing or the contents of an app's synced volume. That count is unchanged by #1791 — it was 31 before that PR and is 31 at this tip — so this release does not make it worse, but it does contradict #1791's thesis. The gating work is tracked as a blocker for the next release, not this one.

Merged PRs

#1774 · #1775 · #1777 · #1778 · #1779 · #1780 · #1781 · #1782 · #1783 · #1784 · #1786 · #1787 · #1788 · #1789 · #1791 · #1794 · #1795 · #1796 · #1797

🤖 Generated with Claude Code

https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5

A node that lost one sync request never asked again, stayed SYNCING and never
reached READY. Permanently, from a five millisecond window.

The orchestrator recorded a peer as asked and then sent. A send into a socket
that is closing does not throw - it is accepted and discarded - so the catch
around it never fired and nothing noticed. The peer stayed in #askedPeers, every
later pass filtered it out, and the node logged "No new eligible sync peers to
ask" and asked nobody. Cleared only by #resetSyncState, which runs on degraded,
so in ordinary operation never.

Found on a real fleet by the residential-dos session: one direction of a 3-node
fleet answered in 74ms while the other timed out at 30s with zero completions,
and node 0's outbound socket closed with code 4019 five milliseconds after the
four requests went into it. That same log line appears 15 times in another
suite's node, which had been read as noise.

The fix is a deletion. FluxPeerManager already tracks this - markSyncRequested,
isSyncRequested, completeSyncRequest - and remove() already drops a peer's mark
when the peer goes. The orchestrator was keeping a SECOND set of the same fact,
and only the peer manager's copy followed reality. It now reads the owner's copy
instead of keeping one, so a peer whose connection went away becomes askable
again by construction rather than by a listener kept in step.

No new event, no un-ask handler, nothing to keep in sync: the duplicate was the
bug, so removing the duplicate is the fix.

The test harness now models that ownership the way production has it, which is
what makes the regression expressible: a peer asked, then removed, must be asked
again. Restoring the private set fails that test and the same-cycle one.

appSyncMinCompletions is 1 under test against 3 in production, which is why a
single lost request is fatal there and merely wasteful in the field.

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

The address a peer is asked to test is no longer an input. A caller that names
it picks where every Flux node connects, and the echo hands back the first bytes
of what answered - so the body form is a fetch primitive aimed at anything a
signed peer likes, loopback and the RFC1918 side of its own router included. The
range guard was never one: portMin is 1 and portMax is 65535. The address now
comes from the socket, which is the rule /flux/addpeer already applies and the
identity every inbound peer is already held to; this node's own operator may
still name one by hand. A request is bounded at 50 ports, which is maxComponents
times ports per component.

Running out is a decision, and it is made once. A verdict and "nobody has
decided" were the same value, so a last attempt whose peer never answered fell
out of the bottom of the loop onto the false it started with and refused the
install having said nothing - on one witness, which is what the corroboration
rule exists to prevent. Every way of running out now resolves in one place after
the loop, which also deletes the two copies of that paragraph inside it.

A peer that will not answer is not a witness. `status: 'error'` carried two
different things: a peer that NAMES the port it could not reach has read
something at this address, and a peer that rejected the request outright - a
stale node list, a clock, a key briefly unavailable at its end - has not. The
first joins the same corroboration rule everything else answers to; the second
is asked past, and logged as what it was rather than as a closed port.

A sibling's answer is signed, as the node at that address. The request side had
always established who was asking; the reply established nothing, and a node's
own record of what it has installed is the whole reason to act on it. The
verifier is extended rather than copied - `socketAddress` binds a signer to a
place, which an answer needs and a request does not.

Also: the picker's reason for excluding a node at our own address is that it is
not outside it, which holds whatever the router does; siblingHoldingPort records
its dependency on NAT hairpinning, and says so when siblings exist and none of
them answered.

The port test loop had no unit coverage of any kind - suite 98 was the only
thing that had ever run it. Eight tests now, five of which fail against the
previous code, and the address derivation has five more of which one does.

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

The self-connection guard added on this branch needs a CURRENT address, and got
one by calling getLocalSocketAddress on every dial. That is an uncached benchd
RPC (executeCall), paid by discovery's deterministic loop, the reconnect queue,
the random draw, the manual add and /flux/addpeer - and it made all five hard
dependent on benchd answering, where only discovery already was.

The form it replaced was not right either: `if (!myPort)` read the port once per
process and never again, so after an address change a node announced a stale one
for as long as it ran. That is the bug the guard exists to catch.

The peer manager already holds this fact. fluxNetworkHelper pushes every refresh
into it through setOwnSocketAddress, from the one place the node learns what it
is, and it already answers this same question for the sync draw - never
ourselves. So the dial reads the owner's copy, and asks only when the node has
not been told yet, which a dial arriving before the first refresh genuinely is.

Freshness is bounded rather than unbounded: fluxDiscovery refreshes it once per
cycle before it dials anything, as do the availability checker and the
address-change handler.

Three tests, two of which fail when the dial bypasses the owner.

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

The signed ask was a constant body - a key and a fixed word - so one captured
signature was a bearer token for /flux/portsinuse on every node in the network,
for ever. It carries the time now, and a node refuses one outside
siblingAskValidityMs.

Refused as STALE rather than as unauthentic: the two want different fixes, and a
node whose clock has drifted should be able to read which from one line. Not
asked of an operator, who authenticated as themselves and has no signature for
anyone to capture.

The same field binds the ANSWER. The sibling signs the ask's time back, so a
captured answer cannot be replayed into a later question either - a signed reply
with no question attached is a recording, and a recording says what was true
then. One field, both directions.

A sibling REFUSING the question is now said out loud rather than joining the
silence a sibling with nothing to report makes.

Also: portNotOurs gets its documentation back. The block had drifted above
PORT_TEST_CORROBORATION, so the constant had two doc comments and the function
none.

Three tests, all three red without the window and the binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The idempotency check matched the tag by name alone, so a tag holding the
release version but pointing at another commit -- one cut by hand, or one master
was later rolled back behind -- was read as "this workflow already ran". Every
remaining step was skipped, including the merged-tree verification that is the
only check covering what master actually holds, and the job reported success
with no tag cut and nothing verified.

The tag is now resolved to a commit and compared to the one being released. A
match is a genuine re-run and still skips; anything else stops the job and names
both commits. Both tag forms are read, because an annotated tag advertises the
commit through a peeled ref and a lightweight tag does not -- and every tag in
this repository today is lightweight, so reading one form finds neither
reliably. ls-remote answers empty when nothing matches, so pipefail is what
separates "no such tag" from a transport failure.

tests/ci/release-tag.sh runs the step as it ships, extracted from the workflow
rather than retyped, across both tag forms and a dead remote; it also asserts the
rest of the job still reads the output this step writes, since an output that is
never written compares equal to nothing and silently disables the release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, and three small ones to release-tag.

A version the comparison cannot read is refused by name rather than judged by
whichever part happens to differ. Splitting on dots and comparing the results let
"8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while
"8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input.

A release whose merged tree exists on no branch is refused at the gate. The merge
preview is a ref nowhere, so its hash is in the signed document only because some
branch tip already holds the same ZelBack subtree. When master carries ZelBack
changes development lacks, nothing will ever sign the merged tree, and the
membership check below would tell the reader to wait for a signing run that never
comes. This refuses first and names the remedy. It compares the ZelBack subtree,
not the whole tree, because master diverging under helpers/ is routine and cannot
change the hash.

release-tag now runs only on this repository, reads its last two values through
env rather than interpolating them into the shell, and logs the poll failure's
message rather than its whole stack trace -- fifteen lines by thirty attempts.

tests/ci/release-workflows.sh covers all three steps, extracted from the workflow
files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all
caught by a case asserting the cause. It also asserts the steps carry no ${{ }},
so a step that becomes uninspectable breaks the tests rather than silently
skipping them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What a releaser does, what each check means, and what every red message is asking
for. The one change to the ritual is that the helpers/hashes.json entry now rides
the release PR instead of being committed to master after the merge -- which works
because the fingerprint covers ZelBack only, so adding the entry cannot change it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, three small ones to release-tag, and the tests for
all of them. a9ed2f0 carried the test file's rename without this content; the
two belong together.

A version the comparison cannot read is refused by name rather than judged by
whichever part happens to differ. Splitting on dots and comparing the results let
"8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while
"8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input.

A release whose merged tree exists on no branch is refused at the gate. The merge
preview is a ref nowhere, so its hash is in the signed document only because some
branch tip already holds the same ZelBack subtree. When master carries ZelBack
changes development lacks, nothing will ever sign the merged tree, and the
membership check below would tell the reader to wait for a signing run that never
comes. This refuses first and names the remedy. It compares the ZelBack subtree,
not the whole tree, because master diverging under helpers/ is routine and cannot
change the hash.

release-tag now runs only on this repository, reads its last two values through
env rather than interpolating them into the shell, and logs the poll failure's
message rather than its whole stack trace -- fifteen lines by thirty attempts.

tests/ci/release-workflows.sh covers all three steps, extracted from the workflow
files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all
caught by a case asserting the cause. It also asserts the steps carry no ${{ }},
so a step that becomes uninspectable breaks the tests rather than silently
skipping them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ci): gate release PRs on the signed hash list, and cut the tag after merge
… write in full

A peer asked to read a port hands back a bounded prefix of what it found, and the
requester's only evidence is finding its secret inside that prefix. The secret
was last in the body, finishing 48 bytes short of the cap - and almost none of
what preceded it was ours. Node writes Date, the transfer framing, and a
Connection it copies from the READING peer, so the margin was settled by a
request string in another service and moved between 20 and 80 bytes with it:
dropping `Connection: close` from portAnswered spends 28 of the 48.

Losing the secret does not read as a truncation. It reads as a neighbour holding
the port, identically on every peer - so refusedPort's two-witness rule, which
exists to filter what does not reproduce, corroborates it rather than catching
it, and no node installs anything.

Every header is now written in fluxHttpTestServer, including the three Node would
otherwise append on its own, and the secret is the first of them: the same 187
bytes, secret ending at byte 67, under Connection: close, keep-alive, no
Connection header, HTTP/1.0 and HEAD. MAX_ECHO_BYTES stays 256 and goes back to
answering only what it is for - how much of a stranger's response a peer may
relay - instead of also having to exceed the length of an answer nobody was
measuring.

isPortOpen's JSDoc had drifted above portAnswered, leaving isPortOpen with none.

Three tests, two of which fail against the previous code: the secret sits in the
header block, and the answer is byte-identical whatever the peer asks for. The
third states the contract the two files never mention to each other, and holds
either way. The suite's tokens were 8 and 12 characters against a real 32.

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

routes.js carried eighteen lines calling /flux/portsinuse unauthenticated and
reachable by anyone, bounded by a thirty-second apicache, protected by a
rejectQueryParameters guard that is not on the route - with three sentences
appended at the end saying it is a signed POST with no route cache at all. The
first thing the next reader met was the design that had been replaced, and the
correction was at the bottom.

It went stale because it was a second copy. portsInUseApi already documents what
the endpoint is for, that it is signed rather than open and why - an anonymous
caller could make this node decrypt its own specifications as often as it liked -
and that an operator may still ask by hand. The route line owns none of that, so
when the handler changed the prose describing it sat in a file the change never
opened. Appending a correction is what produced a comment that contradicts
itself; the copy is deleted instead.

What is left in routes.js is what the route line decides: POST because the ask
carries a signature and is therefore a body, no cache() because apicache keys on
the URL alone and answers before the handler, no rejectQueryParameters because
there is no URL surface for a query string to reach.

portsInUse had taken that comment as its authority - "which routes.js says in as
many words must never happen" - so a second file was already resting on it. It
gives its own reason now.

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

siblingHoldingPort promises in its own doc comment, and again at its call site,
that it answers rather than throws - because the spawner reads a throw as the
APPLICATION having failed and files it in the six-hour pre-install error cache.
Nothing about a question this node could not ask is the application's fault.

That promise was kept a line at a time, and the guess was wrong. The dial carried
.catch(() => null); the verification immediately after it carried nothing, and
deterministicFluxList sits inside that. So one sibling failing to verify left the
function, passed through appSpawner's catch, and stood a healthy application down
for six hours.

Three layers now, saying three different things. The dial keeps its quiet catch:
where a router does not hairpin, every sibling is silent on every cycle, and a
line each would bury everything else - the count is already said once at the end.
Around the rest of the per-sibling work, a catch that loses THAT sibling and no
other, and names it. Around the whole body, the contract itself, so that no line
added here later can break it and no future reader has to notice.

Two tests, both red before: a sibling that cannot be verified no longer costs the
answers of the siblings that replied, and a failure before any sibling is reached
answers no information rather than throwing.

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

The check that a body-supplied address cannot aim this node's probe - the
blocking finding of the second review - could only run on Linux, and nothing
about it is a platform question. It used 127.0.0.2 as the address the caller did
NOT connect from. Linux assigns the whole 127 block to the loopback and refuses
there in 3ms; macOS assigns only 127.0.0.1, so the dial hangs until the port
test's own five-second timeout and the test dies on mocha's two.

::1 instead: a real local address on both, with nothing on that port, refusing in
3ms - and a better "somewhere else" than a second address in the same block. A
host with IPv6 switched off errors immediately too, so the assertion holds either
way.

Skipping it off Linux was the alternative, and that convention exists here for
adjustFirewall and initiateAndHandleConnection. Those need Linux. This needed a
different constant, and skipping would have silenced a security check on the
platform it is developed on.

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

getFluxNodePublicKey answered a failure with the Error object, and signMessage
did the same. That is the worst value either could return: truthy, so a guard on
the value passes; not a string, so nothing type-based notices; and `{}` the
moment JSON.stringify reaches it. Nine callers took the public key and two
checked it, so a node whose key was briefly unavailable did not fail - it went on
broadcasting messages carrying "pubKey":{} that every peer silently refused,
having logged nothing at the point the key went missing.

appSyncOrchestrator is the proof the contract was wrong rather than the callers:
it wrapped both accessors in a try/catch and logged "Failed to sign sync
requests" from a catch that could not fire, because neither of them throws.

Both now answer null, and getFluxNodePublicKey says so in the log - it was silent
where it happened and loud only at the far end.

The question "can this node speak as itself" gets one place to live.
signCheckAppData existed twice, byte for byte, in portManager and
availabilityChecker; both are gone. nodeSigner answers { pubKey, sign } or
nothing, and the five sites that took a public key without ever checking it -
portsInUseApi, siblingHoldingPort, checkInstallingAppPortAvailable,
callOtherNodeToKeepUpnpPortsOpen, checkMyAppsAvailability - each ask once. It
answers rather than throws: its callers sit on paths with very different
tolerances, one of which must never throw at all, and a primitive cannot know
which it is being called from.

No message shape moves. pubKey keeps its position in all five bodies, so nothing
a peer verifies changes; the broadcast envelope signs version+message+timestamp
and the node-to-node asks sign the stringified body, and unifying those is a wire
change that is not this.

enterpriseNetwork and nodeConfirmationService keep the type checks they already
had. A consumer refusing a value it cannot use is a stronger guarantee than a
producer promising not to send one, and they are the only reason a bad key was
ever caught.

Five tests for nodeSigner including a key that arrives as an Error. Three tests
that pinned the old contract now assert null; one of them was called "should
throw error" for something that never threw.

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

Two gates stood here and they answer the same question. The first lets a caller
skip the Fluxnode signature; the second, added by this branch, lets a caller name
the address to probe. But skipping the signature is only ever a PERSON driving
this endpoint by hand, and the sole thing a person gets from it is naming the
address: asking whether the ports are open on the machine they happen to be
sitting at answers nothing anyone wants. Signed-in-but-pinned-to-your-own-address
is a state with no purpose. One question, asked once.

And it is Flux team's. NODE_OPERATOR_OR_FLUX_TEAM reads node-local and is not -
verifyNodeOperatorOrFluxTeamSession takes fluxTeamFluxID and fluxSupportTeamFluxID
alongside the operator - so the comment beside it, claiming the carve-out was
already node-local, was wrong in the direction that matters. What it actually
described was a request-forgery primitive, aimed anywhere including loopback and
the private side of a node's own router, held by thousands of separate
credentials, one per node, for what is a Flux team diagnostic.

An operator who wants to dial out of their own box has a shell on it. Their node
still reaches this endpoint the way every node does, by signing: both product
callers - checkInstallingAppPortAvailable and checkMyAppsAvailability - post a
signed body and never touch this privilege. What is lost is an operator calling
it by hand with zelidauth and no signature, which development allowed.

Two tests. One that Flux team may still name an address; one that pins the
decision rather than the behaviour - the privilege asked for is FLUX_TEAM and
NODE_OPERATOR_OR_FLUX_TEAM is never asked for at all, so the old constant cannot
drift back unnoticed. The second is red against the previous gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported in round two as done, and they were not: the runs removed
then were in portManager.js, and none of that round's three commits
touched this file. Still here, moved by the response rewrite from
14-15 and 56-58 to 38-39 and 96-98.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assignedPortsGlobalApps read the global specifications straight out of
mongo. A version 8 specification seals contacts and compose, and every
port an application holds lives inside compose - so an enterprise
application came back with no ports, was dropped by the length filter,
and the pre-install check concluded its ports were free. The exact
collision this branch exists to prevent, invisible for every enterprise
application on the network.

The key was never the problem. checkAndDecryptAppSpecs opens the blob,
assignedPortsInstalledApps sixty lines above already decrypts, and the
spawner decrypts an application it has not installed at all before
deciding whether to take it. Nothing anywhere refuses on entitlement:
fluxbench forwards the ask verbatim. The global reader simply never
asked.

Both readers now go through one appsWithPorts, which decrypts and then
derives ports through getAppPorts - the one place that derivation lives,
which the review noted these two were hand-rolling around. What each
does about a specification that will not open is now a decision rather
than an accident, and they differ on purpose:

- installed refuses, because portsInUse publishes that list to a Flux
  node sharing our address, so a hole there tells a sibling a port is
  free when it is not. A node holding an enterprise application can
  always read it - they only install on ArcaneOS.
- global answers short and says so, because every node stores every
  global specification including ones sealed to keys it was never meant
  to hold. Refusing would stop installation on every node not running
  ArcaneOS.

The comment at the sibling ask claimed an enterprise application "does
not carry" its ports. It carries them sealed, and that wording is what
kept the missing decrypt looking like a law of physics. It now says the
true reason the ask is needed: the broadcast list only shows what is
already RUNNING, and a sibling that has installed an application without
starting it still holds the router's forward.

isPortAvailable and findNextAvailablePort deleted. No caller anywhere -
findNextAvailablePort was called by nothing and isPortAvailable only by
it. Being exported was all that made them look alive.

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

extractIp(localSocketAddr) was computed in four places inside
trySpawningGlobalApplication, under three names - adjustedIP,
localSocketAddrAddress and localIp - with nothing telling a reader they
were the same value. localSocketAddr is set once and never reassigned,
so one const after the null check serves all four.

The name that made this visible was localSocketAddrAddress, which says
its own type twice and its meaning not at all. It is the node's public
IP without the port, which is what the callers want it for: the apps the
network reports running at the address this node shares with its
siblings.

Two more in the same function, same convention:

- runningAppsOnThisIP -> appsRunningAtOurIp
- filterAppsWithNyNodeIP -> appsPinnedToThisNode, which is what it holds.
  It named a filter rather than the result, spelt My as Ny, and gave no
  hint that these are the applications whose owner named this node -
  pinned being the word isNodePinnedHere already uses for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isNodePinnedHere answers whether the app's own nodes list identifies
this node, by socket address or by collateral outpoint. "Here" carried
none of that, and "pinned" was a word the codebase used without ever
defining, so the surrounding code borrowed it for two more names that
then had to be read back against this function to be understood.

  placementFeasibility.isNodePinnedHere -> specNamesThisNode
  appSpawner pinnedHere                 -> ownerNamedThisNode
  appSpawner appsPinnedToThisNode       -> appsNamingThisNode

Names only; no behaviour, and the stubs and assertions move with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e comments say what actually differs

The hash is filed in the spawn cache at selection, and the spawner's catch adds
nothing for a hash already there. A sibling check that threw would therefore
leave the app exactly where standing down leaves it. What differs is how it is
told: an answer is one log line and a published deferral, a throw is an error
with a stack trace and no event.

Three comments said a throw would file the app in the six-hour pre-install
error cache. It cannot. They now describe the distinction that exists.

The test guarding that distinction asserted a cache neither path writes, so it
could not go red on a throw. It asserts the deferral event instead, which a
throw does not publish - red against the branch turned into a throw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hing it did not announce

The broadcast signer read both key accessors and checked neither, so a node
whose key was unavailable put a message on the wire with null where the key
and signature go, and every peer refused it without a word. It signs through
nodeSigner now, answers null when the node cannot sign as itself, and each of
its callers sends nothing on null.

The running-apps announcement is one fact recorded twice - the location table,
and the event log peers sync from - and sent once. A node that cannot sign
records it nowhere, asked before the first write, so its own view of where it
runs stays the network's view rather than diverging from it.

The Flux Storage request is signed the same way and refuses rather than sending
a header carrying null.

Each guard has a test that is red without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es a stale ask before verifying it

The handler's comment said its only real caller is a sibling. Which ports a
node holds is a fact about that node, and a sibling is one caller for it; the
endpoint answers any listed Fluxnode and the comment now says so, along with
what a captured ask yields inside its window: the port list, signed to the
ask's own time, and nothing else.

The window is checked before the signature. An ask outside it is refused for
nothing, whoever sent it, rather than after a signature check it would fail
anyway. Two tests assert the order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… same rule as the availability endpoint

/flux/keepupnpportsopen took the address to poke from the request body, so a
signed peer could point any node at any address and any ports. The honest
caller is asking about its own ports and posts from the address it means, so
the address comes from the socket, and naming one is the same single Flux
team privilege as skipping the signature. The rule is one function now, shared
with /flux/checkappavailability, rather than two copies of it.

The port list is bounded by what an honest one can hold - every installed
application's ports plus the four node service ports the caller adds - and is
deliberately not filtered by range or by the banned list: the caller's own
service ports sit inside the banned block, and the filter that is right on the
availability endpoint would silently end the keep-alive here. A test pokes
them.

A node operator can no longer drive this endpoint with a login alone. Both
product callers sign; the frontend does not call it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A node with no public key cannot sign whatever its private key says, so the
test that proves nothing is sent has no use for one. The line that supplied
it repeated the suite's test key a thirty-fourth time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ddress that asked

No node in the fleet is behind UPnP, so nothing sends this ask itself. The
runner signs one as node 5 and sends it from where it is not: a real FluxOS
answers 503, because the connect-back goes to the runner, where nothing
listens - the body's address would have reached node 5 and answered 202. The
control, the same body with Flux team auth, gets the 202.

What a unit test cannot prove and this does: express.json(), a real socket,
and a refusal that is a bare status code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A node writes its OWN location row when it announces, and that row expires
RUNNING_EXPIRY_MS after the announcement that carried it. So the interval is a
contract: announce later than the row lives and the node stops being a holder of
its own apps, on its own reading and on every peer's.

The timer was recreated in the finally, so the real period was `interval + the
cycle's duration` - the work sat inside the thing it was being timed against.
Nothing reported it and nothing measured it, so a node whose cycle took 40s
against a 30s interval announced every 70s while every config file said 30. It
is scheduled from the cycle's START now, monotonically, clamped at zero so a
cycle that outran its interval runs again immediately rather than being
scheduled into the past. The debounce the finally was written for survives: any
call still postpones the next scheduled announcement.

The interval is capped at half the row's expiry. Both configurations in use are
already inside it - production 3600000ms against a 3750000ms cap, the harness
30000ms against 31500ms - so nothing changes today. What it stops is the pairing
that deletes the property in silence, which is how this was found: the harness
compresses the interval and the expiry together by 120x and the ratio survived
that, because the term it could not compress was the cycle's own duration.

An overrun is said once on the transition and once when it clears. A node whose
cycle no longer fits its interval decays its presence on the network, and until
now nothing anywhere reported it.

The work stays inside the announcement deliberately. A node that cannot
determine what it is running announces nothing, peers expire its rows, and the
network draws the right conclusion. Separating the timer from the payload would
make the cadence exact and the content stale - a wedged read would announce a
stale snapshot punctually and forever, which is worse than being late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eld by the feed it joined

A subscription remembered a container id, and asked whether anything was
filed under it. Ids survive a container's restart, so a viewer whose feed
had ended read the feed another viewer opened for the same container as
its own, and every later subscribe on that connection was refused for the
life of the connection.

An entry now holds the feed record itself, and a feed leaves the map only
through closeFeed, which marks it - so "not filed" and "marked closed"
always agree, and a dead subscription is replaced on sight rather than
mistaken for a live one.

One slot becomes a map, because nothing about a log stream is exclusive.
The terminal takes one container per connection because `cmd` and `resize`
name no session; everything here is addressed by room and keyed by
container, and one docker stream already serves every viewer of a
container however they are connected. Measured on a node: ten containers
followed by one connection cost what ten connections following one each
cost, and save nine sockets. Ten, because an app is capped at ten
components; refused before the signature check, so a connection at its
limit cannot spend the node's verification on a subscribe it cannot have.

The rest follows from a connection being able to follow more than one:
every message names its container, or a viewer of two cannot tell whose
lines arrived; unsubscribe takes the name or id of one of them, and no
argument still means all of them.

Three defects in the same file went with it. A failed open now empties
its room - a viewer left behind was handed the lines of whatever feed
opened for that id next - and does it only when the feed it claimed is
still the container's, so a stale failure cannot empty a live feed's
room. A pass whose feed was closed while the daemon answered says the
stream failed instead of reporting itself subscribed to nothing. And a
pass that fails releases only what it still holds, rather than taking
down the subscription a later pass on the same connection has taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a stop of it is final

Two numbers decided one thing. A node writes its own location row when it
announces, that row expires locationTtlS later, and peerNotifyIntervalMs
was a second, hand-set number that had to stay under it - with a clamp and
a ceiling in the scheduler to catch whoever forgot. Both configurations
had already been set to the same ratio by hand: two announcements to a
lifetime, with a little slack.

That is now the derivation, and the second knob is deleted. Two, because a
node must be able to miss one announcement and still be refreshed before
the row lapses; four percent, the slack that miss needs so the refresh
lands before the row expires rather than exactly on it. Production's 7500s
gives 3600s and the harness's 63s gives 30s - the values both files were
carrying - so nothing changes on any node, and there is no longer a
pairing an edit can put wrong in silence.

The announcement loop also gets a lifecycle. It ended each cycle by
arming the next one, so a stop taken while a cycle was running was undone
a moment later by the work it was trying to end; and the stop returned
before that cycle had finished. Whether the node announces is one flag
now, consulted by every path that ends a cycle, and the stop holds the
cycle's lock so it returns when the cycle has actually let go. An
announcement asked for directly - an install completing, a container
starting - is still answered, because that is a request to announce and
not a request to resume announcing.

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

An AbortSignal fires once and has to be replaced to run again, so it
cannot also carry whether a loop is wanted: abort() reissued it as soon as
the lock freed, and loop() read it after the runner had had a chance to
yield. Whether a stop held came down to which of the two resumed first,
and for a runner that took no lock it did not hold at all - measured: the
loop ran twice more after the abort, and the timer it armed kept the
process alive.

The controller now has a state of its own, taken at the first line of the
stop, and that is what decides whether an iteration schedules another. A
generation goes with it, so an iteration already in flight when the
controller was stopped cannot arm a timer beside the loop a later start
has begun. The signal goes back to being what it is - a cancellation for
the work in flight - and is reissued when the abort finishes, because it
is also handed to clients that have no loop and are rebuilt on stop.

`abort` waits on the default lock and on any lock added with blocksAbort.
A named lock usually holds a caller's own coordination - the network
state manager's `fetcher` is how its readers wait for a fetch - and a stop
that waited on every lock deadlocks against the work it is stopping.

`running` covers stopping as well as running, since a caller guarding its
start on it must not read a stop in progress as stopped and reset its own
state for a loop that startLoop then refuses to begin. `active` is the new
question: is this run still wanted. It is the condition for a runner that
loops on its own rather than returning a delay, which the signal cannot
answer - and it is false outside a run, so a function called both as the
runner and directly must still ask the signal. The syncthing events
consumer is the one loop of that shape.

A runner that throws ends the loop with a log rather than a rejection
nobody handles, which in this process is an exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it counts what is in flight

A feed and the records naming it are two halves of one subscription, and
closing a feed tore down only one. The record stayed behind, counting
against its connection's limit for the life of the socket while naming a
container it no longer followed - and the id it named may since have been
filed to another viewer's feed. It is only replaced when the same id is
subscribed again, which a redeploy never is: the container comes back with
a new one.

A feed remembers its viewers as their own records now, so closing one
forgets them where it already empties their room. Nothing at module scope
can see a connection's map, so the record carries the way to forget it,
guarded by identity: a later pass may have taken that container over and
its record is not an older feed's to remove.

The limit counts the passes still in setup as well as the settled ones. A
pass reaches the map only after the signature check and the docker lookup,
so counting the settled ones alone let a burst arriving in one tick run
both in parallel and be refused afterwards - 60 verifications against a
limit of 10, measured. That is the cost the refusal exists to avoid, taken
as many times as the subscribes were sent.

'subscribed' says one thing: you are receiving this container. It is
answered now only for a record that is a member of its feed's subscribers,
so a repeat arriving while the first pass is still opening is left to that
pass to answer for both. Answering it early told a client it was
subscribed to a feed that had not opened yet, and nothing corrected it if
the open then failed.

And every refusal names what the client asked with, as the batches already
name their container. A connection following several of them could not
attribute 'Not authorized.', 'Container not found.', a refusal at the
limit, or 'Error following logs.' to any of them.

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

`tail` bounds the window over the FILE and `since` trims that window
afterwards - measured on a live daemon: asked for the last 1884 lines
at-or-after a timestamp it answered 1883, having dropped a leading line
stamped before it. The read was bounded at one more than a page on the
opposite premise, which the file asserted in one comment and contradicted
in the one above it.

A position's `count` is a place in the sequence docker returns for a
timestamp, so that sequence has to start at the same line each time. A
window bounded over the file slides forward as the container writes, and
when it slid past the reader's overlap the count was measured from a
different first line and the lines between were dropped - with `skipped`
and `rolledOver` both false, because an answer shorter than a page reads
as the whole set having fitted, which is exactly what a trimmed window
produces.

The window carries the overlap as well as the page, and the overflow test
measures the new lines rather than new-plus-overlap.

Every stub in the suite answered a fixed buffer whatever it was asked for,
so `tail` and `since` had no effect on any answer and no test could see
the order they were applied in. There is a fake daemon now that honours
both, built to what was measured, and a two-poll test through it: against
the old arithmetic it loses two lines of three and reports success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four comments said a redeploy that could not reinstall had touched
nothing, so the app was as it was and the reconciler would converge it.
Neither half is true: the removal above has already taken the app's
containers AND its local row, and with no row there is nothing to
converge. The installer's own FAILED return said it had told the network,
and it removes with `sendMessage` false.

So both outcomes end with the node holding no containers, no row, and
peers keeping a location record until it expires on its own. That is
recorded where it happens now, along with why it is not answered here: v9
replaces this path with the operation registry, broadcasting the removal
would cost the app's data, and a retry belongs to something that owns the
whole redeploy rather than to its last step.

The tests stubbed `registerAppLocally` with `true` and `undefined`, which
stopped meaning anything when it began answering an InstallOutcome -
anything that is not INSTALLED is read as a failed reinstall. So a test
asserting that a SUCCESSFUL component redeploy does not uninstall the app
was asserting it of the failure branch, which also happens not to. The
stubs answer an outcome, and that test now proves it reached the success
path by the event that is published on success only.

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

A positioned read is bounded by `tail`, and `tail` was sized from
`position.count` - a value the caller writes into an unsigned token. The bound
the docblock rests on was the caller's to choose.

Bounded by what the log can physically hold rather than by the page: `count`
grows past a page whenever a burst lands inside one millisecond, and a window
that does not reach back over the overlap is answered entirely by lines the
reader already holds - it is handed nothing, its position never moves, and
neither `skipped` nor `rolledOver` says so.

`max-file` and `max-size` move out of appDockerCreate into constants both it
and the read derive from, so what a log holds and what a read may ask for stay
one fact. The decode also declares that it asked docker for timestamps, which
is what lets the decoder tell the stamp on a line from the copies docker puts
on each 16KB chunk of it.

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

Catching it kept the node up and left the loop stopped for the life of the
process - nothing reads `running`, and nothing starts these loops again after
boot. What a node does with an unexpected fault is already decided at the
process level: apiServer's uncaughtException handler logs it and exits, and
systemd brings every subsystem back thirty seconds later.

Every runner this serves guards the failures it expects, so a throw arriving
here is one nobody predicted, and the crash policy for those belongs to the
application rather than to a generic utility.

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

A line is held until its newline arrives, which nothing obliges a container to
send. Unbounded, a process writing a large blob to stdout decided how much of
the node's memory a viewer cost - measured, 10MB of newline-free output holds
10MB and emits nothing, growing to V8's 512MB ceiling where every further chunk
throws and the feed is finished still holding it - and then how much crossed the
socket, because a line that completes is delivered whole.

The decoder cuts every line to a bound and drops the rest of a cut line rather
than splitting it into lines the container never wrote, so delivery is bounded
as well as memory. What was cut is reported on a `truncated` event: its own,
because `skipped` counts lines that never arrived and this is a line the viewer
HAS, missing its tail. Counted per line rather than per batch, and sent after
the lines rather than before them.

Docker splits a message longer than 16KB into 16KB chunks and copies the
message's stamp onto every one - measured on a live daemon, three chunks of
len=16415 all carrying the first one's. Joined as they arrived, any line over
16KB came back with docker's timestamps spliced through its body, which is a
line the container never wrote and one a reader cannot tell from one it did.
A continuation's leading token is now dropped when it is byte-identical to the
stamp its line began with, so content that merely looks like a timestamp is
left alone and a daemon that stops repeating it falls back to passing the body
through.

Two more the review found. The limit refused the tenth container to a connection
holding nine: a pass is in `pending` until its setup ends and in `following`
from the moment it takes one, so adding both charged an open twice. And a stream
outlives the feed it was opened for - the events after a destroy still reach its
handlers, by which time a later viewer's feed is filed under the same id, so a
dead subscription's lines went to the live one and a dead stream's error closed
a subscription it never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suites 59 and 84 required lines to arrive in the container's own numbering.
Docker merges stdout and stderr as it reads them and can serialise a line behind
the one that follows it - measured on the gate box over 40,000 alternating
lines: 38,078 order breaks, 2,817 backwards timestamp steps, nothing missing.
The fixture alternates the two streams deliberately, so the suites forbade the
property they exist to produce, and 59 went red on a loaded gate for it.

Both now assert the set is complete - no duplicates, no gaps once sorted - with
the window closed by what arrives after it, so a line docker merged late is not
read as one the node never delivered.

The fixture also writes a line with no newline in it on demand, and 84 covers
the cut end to end on a container of its own: the line comes back at the bound
and carrying only its own stamp, every notice reports a whole number of cut
lines, and the numbered lines continue across it. Docker frames that blob into
eighty pieces, which is the part no crafted buffer can stand in for.

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

fix(apps): a missing container must not crash FluxOS, a failed redeploy must not uninstall the app, and a log poll must lose nothing
2919141 was the chain tip the fork was written at, which is the one height it
could not be: a node on an older FluxOS reads only the fork at 1851659, so a
message signed by any of the four new addresses is rejected until it updates.
A fork at the tip gives the network no window to update in.

It moves to 2951000 - about a week past the tip as of this commit (2931010, 7th
September 2026), at the ~30s blocks the chain is producing. The addresses and
the shape of the entry are unchanged; only when it takes effect is.

The harness chain start moves with it. tests/unit/harnessChainStart.test.js
fails if any gate in the production config sits at or above it, and for the same
reason it fails: a chain starting at 2920000 would run every suite on the
pre-fork branch of the rule this PR adds. 2952000 is above the new fork and
crosses no other gate - the next highest in the config is 2176519.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167SV41toNw53WkGcHJ3cE2
feat(auth): the support team is a list of Flux IDs, not one
Brings back the two commits that landed directly on master after the v8.17.1
release: the v8.17.1 fingerprint in helpers/hashes.json, and the tampering
blocklist additions. Both are under helpers/, so neither touches the hashed
ZelBack tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5
Steps 1 and 2 of RELEASING.md for the development -> master release PR.

package.json moves 8.17.1 -> 8.18.0, which is what release-gate's "version
moves forward" check reads.

helpers/hashes.json gains f91c1a1ec2604642ddd69edc121d18b6, the md5 over
./ZelBack at this tip. Neither file is inside ZelBack, so committing them does
not move the fingerprint. It is already carried by the signed document central
serves (seq 41), so release-gate's signed-document check has its answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ooCPUCraGLFGvtFh35ZA5
@gitguardian

gitguardian Bot commented Sep 7, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
10071586 Triggered Generic High Entropy Secret c053c1e tests/unit/fluxCommunicationMessagesSender.test.js View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@MorningLightMountain713 MorningLightMountain713 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ack

@Cabecinha84
Cabecinha84 merged commit 10d0954 into master Sep 8, 2026
6 of 7 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.

3 participants