A heartbeat that cannot be green without evidence - #338
Merged
Conversation
Apolloccrypt
force-pushed
the
feat/heartbeat-bewijs
branch
3 times, most recently
from
September 2, 2026 14:53
9c16b4b to
04f85b4
Compare
Run 33624449015 this morning reported all four steps green. Two of them proved
nothing, and the log says so:
ok 1 - parasign: an envelope is created, signed and notarised # SKIP PARASIGN_CANARY_KEY not set
duration_ms: 1.284603
ok 2 - parasign: the signed document comes back stamped # SKIP PARASIGN_CANARY_KEY not set
duration_ms: 0.115257
# pass 1
# fail 0
# skipped 2
node --test prints a skipped test as `ok N - name # SKIP reason` and counts it
in `# pass`. Both canaries used { skip: KEY ? false : 'reason' }, so a missing
secret meant a skip, a skip meant ok, and ok meant a green tick. The duration is
the part that cannot be argued with: creating an envelope, signing it and
verifying an ML-DSA-65 signature does not happen in 1.28 milliseconds. Nothing
left the runner. The failure filter had the same blind spot from the other side,
scanning for ^(x|not ok|AssertionError|fail) while a skipped test begins with ok.
Neither secret exists. gh secret list returns four names, none of them a canary
key, and there are no environment or dependabot secrets. So the keyed ParaSend
route and the entire ParaSign product have never been checked, not once, by
anything.
THE RULE
A monitoring step may never have an escape hatch. If what it needs is missing,
that is red, with the name of what is missing. A reason belongs in the error
message, not in the verdict.
WHAT IS HERE
scripts/heartbeat/, four steps, no test runner and therefore no pass/skip
semantics in the way:
surface /health; /v2/health/deep, whose three possible worlds are told apart
from the answer (405 mode-dropped, 200 in full mode, 401 behind the
internal gate) and whose body is asserted because the handler
answers 200 even when overall is red; and the six hosts x two ParaID
issuance paths, so the deny inserted by hand into nginx on 01-09 is
checked hourly from outside. A 400 there means the handler is
reachable again.
parasend both routes, anonymous and keyed. Upload, fetch, compare the bytes,
require the burn. Plus a payload that does not match its hash, which
must be refused before storage.
parasign-receipt /v1 with the psk_test_ key: envelope to completed, then
the .psign verified offline - the notary counter-signature against
the key published at /v2/pubkey rather than the key printed inside
the receipt, and every party signature against a message rebuilt by
the relay's own signMessageBytes.
parasign-public-sign /v2, the route a recipient's browser actually posts to,
which the sandbox auto-signer never touches. It submits a signature
that does not verify and requires a 400 first, because a relay that
rubber-stamps would sail through every positive check ever written.
Then it signs for real, verifies offline, and folds the new CT leaf's
inclusion proof back to the published tree head with ctNodeHash.
Every step writes heartbeat-evidence/<step>.json with ids, hashes, statuses and
timings, uploaded as an artifact on every run. runStep fails a step that recorded
no proof even when nothing threw, so a step cannot pass by doing nothing again.
Failure emits one ::error:: annotation, which GitHub renders above the log, and
opens or comments on a single issue titled "Heartbeat rood"; green closes it.
ONE THING FOUND WHILE BUILDING IT
@noble/post-quantum 0.6.1 takes verify(signature, message, publicKey). The
ParaSign canary called verify(publicKey, message, signature), which throws a
RangeError on the installed version. Even with its secret set it could never
have passed. The argument order now lives behind named wrappers in one place,
with a round-trip check at import.
ALSO
The hourly half of product-heartbeat.yml moves here, so there is one alarm with
one evidence artifact instead of a job whose green tick meant different things in
different steps. product-heartbeat.yml keeps the browser suite as the
pull-request gate. The two canary suites are deleted; their coverage is in
scripts/heartbeat/ and they carried the bug. The name-based exclusion they needed
in test.yml goes with them, which is how a suite ends up running in no workflow
in the first place.
docs/heartbeat.md: what each step proves, which secrets are needed and why, and
how to run it by hand.
WHAT THE REVIEW CHANGED
Four of these were comments promising behaviour the code did not have, which is
the same failure as a skipping canary in a smaller frame.
dry run lib.mjs said a dry run can never stand in for proof and then
exited 0 with four green steps. It now sets summary.ok false and
exits 2 however many steps passed; steps_ok records separately
that the wiring is sound.
cleanup parasign.mjs said "cleanup first, so a failure anywhere below
still tries to retract" while the void ran only on the success
path, so the runs that mattered were the ones that left an
envelope behind. Wrapped in try/finally.
the issue the workflow said "reopened" and searched state:open, so every red
episode after a green one would file a new issue. It now searches
state:all by title, skips pull requests, reopens and comments.
/health recorded the status code and called it alive. nginx, a cache, a
maintenance page and a parked domain all answer 200. It now
requires JSON, an ok/status field that says healthy, and a mode or
version, so a wrong service on the right port cannot pass.
NOTHING WATCHED THE WATCHER
scripts/heartbeat/ had no test coverage, and it showed: a swapped ML-DSA-65
argument order lived in it while the whole suite stayed green. That is the exact
bug the old canary carried. tests/heartbeat-lib.test.mjs now pins the argument
order (including that the wrong one THROWS rather than returning false, which is
why it was invisible), the recipe-v4 sign message, that runStep fails a step
recording no proof, that a missing secret is a named failure, and that a dry run
cannot exit 0. Reintroducing the swap fails two of its tests.
The self-check moved off module load into assertMlDsaWrapper(), called at the
top of each step. A throw during import happens before run.mjs has a step to
attribute it to, so no summary.json and no annotation would be written and the
alarm would degrade into a stack trace. It now arrives as a named red step.
The suite needs @noble/post-quantum, so the root integration job installs the
root dependencies. Making it skip when the package is absent was the obvious
alternative and it is precisely the bug being fixed.
docs/site-claims.md, tests/README.md, docs/frontend-loading-contract.md and
deploy/DEPLOY-3.1.md no longer point at the deleted suites.
THE SLA PAGE, AND THE ORDER THIS HAS TO HAPPEN IN
Section 5 of /sla was made false by this branch and is rewritten here rather
than left. It said "the product-heartbeat workflow runs hourly", and that file
no longer has a schedule; it said the four sector relays "are not part of that
hourly run", and surface.mjs POSTs to all four and goes red if one does not
answer.
What it says now is what is true, which is less than before: /status is the only
thing running today. The hourly check is built and public, and the paragraph
says it runs hourly ONCE ENABLED and is switched off until its credentials are
in place.
That last clause is not hedging, it is the third finding. The two canary secrets
do not exist and can only be created from the machine that holds the keys.
Merged with a live schedule, this alarm would go red at :17 every hour and
comment on the same issue twenty-four times a day, for a reason everybody
already knows. An alarm that cries hourly for a known reason is one people learn
to ignore, which is how a monitor dies a second time. So the job carries
if: github.event_name == 'workflow_dispatch' || vars.HEARTBEAT_ENABLED == 'true'
workflow_dispatch ignores the gate, so the first run can be started by hand and
watched, which matters: it is the first time anything will have checked ParaSign
against production. docs/heartbeat.md carries the three commands in order, two
secrets then a manual run then the variable, plus how to switch it off again.
site-claims.test.mjs is tightened rather than relaxed. The workflow name is
matched exactly and product-heartbeat is now forbidden on the page. The gate is
pinned in BOTH directions: while it is there the page must say the check is off,
and once it goes the page must stop saying so, so setting HEARTBEAT_ENABLED
fails this test until section 5 is rewritten. The sector clause lost its
exemption and is pinned to surface.mjs reaching all four hosts for the ParaID
deny check. And "a run that fails opens an issue" is now tied to the if:
failure() step that calls issues.create, instead of resting on the workflow
merely existing.
Each of those was checked by breaking it: removing the gate, changing if:
failure(), and restoring the old sector sentence each fail the test with a
different message.
Apolloccrypt
force-pushed
the
feat/heartbeat-bewijs
branch
from
September 2, 2026 15:06
04f85b4 to
5bc4c75
Compare
Apolloccrypt
added a commit
that referenced
this pull request
Sep 2, 2026
main moved while this branch was open. Rebased onto 99909a1 and folded what arrived into the CHANGELOG section, because a release section that is a snapshot of the moment it was written is the exact failure this PR is fixing. New in the section: the ParaSign product page at /parasign (#325), the messaging guide (#331), the homepage rewrite for a buyer (#328), the site claims pinned to the code that makes them true (#327), the heartbeat that cannot be green without evidence (#338), and the route suites that boot a real relay.js (#341). Count in the intro goes from 268 commits and 52 PRs to 277 and 58, and the intro now says it was rebased rather than pretending it was written in one pass. #341 is worth naming for what it is: point 3 of the toekomstbestendigheid report was that relay.js, 6488 lines and 68 routes, was loaded by no unit test at all. It is now loaded by the route suites. That point is not closed, but it is no longer zero. Two conflicts, both resolved toward main's newer structure: - product-heartbeat.yml: #338 moved the live job out into heartbeat.yml. Took main's file whole and applied the Node 24 bump to both files instead, so the bump follows the job rather than the filename. All seven node-version entries across the five workflows now read '24'. - test.yml: #341 added a second silent-suites gate for the route job and set both expected sets to empty. Kept main's exclusion lists and applied --test-reporter=tap to both steps. That second one matters more after the rebase than before. With a non-empty expected list, the spec reporter made the gate fail, which is how it was found. With an empty expected list, the spec reporter makes it PASS: the grep finds nothing, silent is empty, and a gate that can no longer see anything reports green. A dead gate that reports green is precisely what these two gates exist to prevent, so both now pin the reporter rather than inherit a Node default. Tests after the rebase, on Node 22 and again under node:24-alpine: relay 175, admin 40, root 145 (143 pass, 2 pre-existing skips), both silent-suite gates empty as expected. static-sanity PASS including the style guard, bash -n and eslint clean. The root job now runs npm ci first, per #338; tests/README.md and the local commands in docs/RELEASE.md match what CI does.
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
relay.js is 6488 lines and 68 routes and, until this branch, no unit test loaded
it. The coverage table did not list the file: it was only ever started as a black
box by three suites that then killed it with SIGKILL, which is also why it could
never contribute a coverage profile. Five new suites boot the real relay.js and
drive it over HTTP.
Nothing here touches the network or production. The relay under test binds
127.0.0.1, gets no Mollie key, and every path that would default to /data is
redirected into a scratch dir.
What is covered
route-auth-gateroute-envelope-lifecycleroute-transfer-burnroute-billing-entitlementsroute-rate-limitsHighlights, rather than a list of asserts:
is absent, malformed, empty, or wrong-shaped. It can never turn into a 400,
which is the shape the ParaID bypass had. A de-activated key answers byte for
byte like an unknown one, so the 401 is not a key-existence oracle. An
unconfigured ADMIN_TOKEN fails closed.
bad_signature: a signature over another document, over another party index,from a key other than the one presented, and pure noise. The slot stays open
and the good signature still lands afterwards.
from the receipt's own fields, verifies each party signature and the notary
signature with the raw engine, checks the notary key hash, and shows that
changing one character of the document hash breaks the notary signature. It
never asks the relay whether the receipt is good.
X-Paramant-Burned: true, the second is 404, and that 404 is identical to the 404 for a hash thatnever existed. A refused read by another tenant does not consume the view.
asks the same question again. That round trip is what billing: collect a second period, and make paid_until survive a restart #315 actually broke.
Coverage of relay.js
Measured with c8 over the suites exactly as CI runs them,
--all --include relay.js, so the file appears even when nothing loads it.The before figure is not an artefact of the tool:
inbound-hash-verify,deep-health-gateandbilling-stance-bootdo boot relay.js, but they SIGKILLthe child, and a killed process writes no V8 profile. The harness stops a relay
with SIGTERM (relay.js already handles it, zeroizes its blobs and exits 0), which
is what turned the profile on.
Sabotage, per suite
One line changed, the suite run, the change reverted. Each is a real change to
production code, not to a fixture.
relay.js:4214auth gate condition replaced byfalse(the gate lets everything through)!keyDatacheck behind the gate, which is defence in depth working as intended.relay.js:4529views_remaining ... - 1becomes- 0(nothing ever burns)relay.js:5657sigVerifyreturnstrueunconditionallyrelay.js:1643_mutateUsersJsonbecomes a no-op (this is the #315 mechanism: entitlements live only in memory)relay.js:1404and1427create budget 50 becomes 500git statuswas clean after each revert.CI
relay-crypto-tests, the job that installs the relaydeps and builds @paramant/core. relay.js requires
redisand the engine atload, so they cannot run in the no-install unit job, and they are excluded from
its glob by name.
redis:7.4.8-alpine, the same tagdocker-compose runs, on 6399). The envelope store is redis-only
(relay.js:5650): without a server every
/v2/envelopesroute answers 503 andthe ParaSign lifecycle cannot be tested. It is also what makes the fleet-wide
half of the create limiter measurable, two relay processes against one budget.
expected set. Every suite prints its closing line through
summary()intest/_requires.js, so a suite that runs and asserts nothing is caught heretoo. In this job it has redis and the engine, so a skip means broken, not
under-equipped, and the gate fails rather than keeping a list.
parasign-signs-quotamoves into that step too. It needs redis and nothingelse, and it ran in one place only: the no-install unit job, where it has no
redis and skipped on every single run. Its five checks on the monthly signs cap
(the metering that once sat behind a dead
&& false) had therefore never runon a pull request. With a redis service they do, and the unit job's
expected-silent list is now empty: every suite that runs there asserts
something.
|| trueon the grep that collects the silentsuites. GitHub runs a
run:block underbash -eand grep exits 1 when itmatches nothing, so an empty result, which is the good case, failed the step.
That is what turned the first fully green route run into a red job; the suites
themselves were 56 pass / 1 todo on that same run.
Findings
Found while writing the tests. Not fixed here: this branch is tests only.
1. An expired paid tier keeps granting on every account-level read
lib/entitlements.js:328-344mergeAccountRecord()copiesplan_parasignandplan_parasendoff the api-key records onto the merged account record, but nottheir
paid_until_*fields. Its own doc comment explains that those fields canonly live on the key records, because the accounts summary never carries the
per-product plans. So the merged record reaches
getEntitlementswith a paidtier and no period, and
entitlements.js:137then correctly reads "no recordedperiod" as "never expired".
(same record,
paid_until_parasign20 days in the past)Reached through
entitlementRecordOfat:relay.js:5316GET /v2/admin/entitlements/:account_idrelay.js:5990the signs-quota gate on POST /v2/envelopes/:id/signrelay.js:1804the plan a newly mintedpsk_key inheritsThis is the same class as #315, one step further along: the date is written and
persisted correctly, and then dropped on the way to the gate that reads it. The
fix is two lines in
mergeAccountRecord, but it changes billing behaviour, so itbelongs in its own PR with a decision about accounts that are already lapsed.
Encoded in the branch as a
todotest (route-billing-entitlements, "#315: aperiod that HAS passed stops granting"), which states the correct rule and fails
today without failing the run, plus a passing test named
FINDING:that pins thedivergence between the two read paths as evidence. Both go away with the fix.
2. A ParaSend download answers with a 19 KB response header
GET /v2/outbound/:hashreturnsX-Paramant-Receipt, a base64 delivery receiptcarrying a full ML-DSA-65 signature and the inclusion proof. Measured on this
branch: 18551 bytes for that one header, 19560 bytes of response headers in
total (relay.js:4563-4590).
maxHeaderSizeis 16384, so a Node client usingfetch()or a default
http.requestcannot download a blob: it throwsUND_ERR_HEADERS_OVERFLOW. This is how the finding surfaced, the test clienthit it on the first download.
proxy_buffer_size(nginx-selfhost.conf,deploy/nginx-paramant-live.conf,deploy/nginx-paramant-public.conf,deploy/nginx-selfhost.confall lack it). The nginx default is 4k/8k, whichanswers 502 on an upstream header this size. Whether production is configured
differently was not checked, that is a production read.
The obvious shape of a fix is to move the receipt into the body or behind a
second endpoint, or return only a receipt id in the header. Not touched here.
3. Two smaller inconsistencies
relay.js:4386takes the TTL ceiling with a'community'fallback for a keywith no plan,
relay.js:4391takes the max_views ceiling for the same key witha
'pro'fallback. Two different defaults, one line apart, and the morepermissive one is on the views.
relay.js:1607OUTBOUND_RATE = { free: 50, pro: 500, enterprise: Infinity }has no
communityand nobusinesskey, so both fall to?? free= 50/hour.A Business account pays for the highest ParaSign volume and gets the free
outbound rate.
4. A testability gap, not a bug
The only code path that writes
paid_untilis the Mollie webhook, andlib/mollie.js:12hard-codesapi.mollie.comwith no override (deliberately, asan anti-SSRF measure). So the write half cannot be driven offline and this branch
pins the read-back and the admin write instead. If that path is ever to be tested
end to end it needs an injection seam that does not widen the SSRF surface.
How to run it
What the review changed
Four of the findings were comments promising behaviour the code did not have.
That is the same failure as a skipping canary, in a smaller frame, so they are
worth listing.
lib.mjs:26andrun.mjs:51: a dry run can never stand in for proofsummary.okfalse and exit 2 however many steps passed.steps_okrecords separately that the wiring is sound, so the two are never confusedparasign.mjs: "cleanup first, so a failure anywhere below still tries to retract"try/finallyaround the receipt walk. The runs that left an envelope behind were exactly the ones that matteredstate: open, so every red episode after a green one filed a new issuestate: allby title, skips pull requests, reopens and comments/healthmodeor aversion. nginx, a cache, a maintenance page and a parked domain all answer 200Nothing watched the watcher
scripts/heartbeat/had no test coverage, and it showed: a swapped ML-DSA-65argument order lived in it while the whole suite stayed green. That is the same
bug the old canary carried.
tests/heartbeat-lib.test.mjs(11 tests) pins:than returning false, which is precisely why it was invisible
party index and document hash
runStepfails a step that recorded no proof, and writes the evidence eitherway
requireSecretfails by name, and treats a whitespace-only value as unset(an unset repository secret expands to an empty string)
Reintroducing the swapped order fails two of its tests. Verified by doing it.
The self-check moved off module load into
assertMlDsaWrapper(), called at thetop of each step. A throw during import happens before
run.mjshas a step toattribute it to, so no
summary.jsonand no annotation would be written and thealarm would degrade into a stack trace. It now arrives as a named red step, with
the noble
RangeErrorcaught and rewritten.The suite needs
@noble/post-quantum, so the root integration job installs theroot dependencies. Making it skip when the package is absent was the obvious
alternative and it is exactly the bug being fixed.
Docs pointing at deleted files
docs/site-claims.mdrow 8,tests/README.md,docs/frontend-loading-contract.mdand
deploy/DEPLOY-3.1.mdno longer send a reader totests/transfer-canary.test.mjsortests/parasign-canary.test.mjs. TheDEPLOY entry for #316 keeps its history and records that #338 replaced it, and
why.
Rebased on
mainthrough #327.The SLA page, and the order this has to happen in
Section 5 of
/slawas made false by this branch, so it is rewritten hererather than left for a follow-up:
schedulesurface.mjsPOSTs to all four and goes red on status 0What it says now is what is true, and it is less than before:
/statusisthe only thing running today. The hourly check is built and public, and the
paragraph says it runs hourly once enabled and is switched off until its
credentials are in place.
Why "once enabled", and the gate
That clause is not hedging, it is the third finding.
PARAMANT_CANARY_KEYandPARASIGN_CANARY_KEYdo not exist and can only be created from the machine thatholds the keys. Merged with a live schedule, this alarm would go red at :17
every hour and comment on the same issue twenty-four times a day, for a reason
everybody already knows. An alarm that cries hourly for a known reason is one
people learn to ignore, which is how a monitor dies a second time.
So the job carries:
workflow_dispatchignores the gate, so a run can always be started by hand,including before the variable exists, to see exactly which secret it still
wants.
docs/heartbeat.mdhas the commands in order:Step 3 matters more than it looks: the first real run is the first time anything
will have checked ParaSign against production, so it is also the first chance to
find that ParaSign has been broken for the eleven days nobody signed anything.
Worth watching, not worth discovering at three in the morning.
The test got stricter, not looser
product-heartbeat workflowis nowforbidden on the page
the check is switched off, and once it goes the page must stop saying so. So
setting
HEARTBEAT_ENABLEDfails this test until section 5 is rewritten. Thefollow-up cannot be forgotten, because the switch itself trips it
pinned to
surface.mjsreaching all four hosts for the ParaID deny check, andthe page must carry both halves: what the run does there, and that uptime is
not measured
if: failure()step that callsissues.create, plusissues: writeEach was checked by breaking it:
the gate is gone, so the page must stop saying the check is switched offif: failure()changed toalways()the workflow must have a step that runs only on failurethe page must say both what the run does at those hosts and what it does not measure