The one document you read on day one. Everything in it was executed in a clean
clone on 2026-09-02 against origin/main at faf599e; the numbers below are
what that run printed. Every claim names the file it comes from.
If a step here disagrees with what your terminal says, the terminal is right and this file is a bug. Fix it in the same PR.
Five sentences, quoted from docs/brand/messaging.md:
- Paramant is for small professional firms that would rather not put client
files on an American cloud: legal, finance and healthcare practices in the EU
(
docs/brand/messaging.md:308-312). - You send a contract out for signature, or send a file that is gone the moment it has been read; everything runs on servers in Germany (same lines).
- The promise, in one line: "Anyone in the EU can sign and send securely for
free, forever. Organisations pay for volume, never for security"
(
docs/brand/messaging.md:59-60). - Technically that is ML-KEM-768 + AES-256-GCM client-side encryption relayed
through RAM (ParaSend, burn-on-read) and ML-DSA-65 signatures generated in
the browser with a public append-only proof log (ParaSign) (
README.md:12,docs/brand/messaging.md:177-181). - A ParaSign signature is a Simple Electronic Signature under eIDAS, not an
advanced or qualified one, and the site says so in those words
(
docs/brand/messaging.md:209-211).
The free plan is called Community. Not Free, not Starter, not Basic
(docs/brand/messaging.md:85-87). See section 5.
What is in each directory, and which test or workflow fails when you break it.
| Directory | What is in it | Guarded by |
|---|---|---|
relay/ |
relay.js, one ~6500-line HTTP server with ~68 routes: auth gate, ParaSend transfer and burn, ParaSign envelope lifecycle, billing and entitlements, CT log, rate limits. envelope.js holds the envelope recipe (v4 binds the signer pubkey). lib/ is 29 extracted pure modules. crypto/ is the pluggable-algorithm registry and wire format. |
test.yml jobs relay-unit-tests and relay-crypto-tests; test.yml job undefined-names (eslint no-undef); build-image.yml (drift gate, path-gated on relay/**) |
relay/test/ |
56 suites plus two helpers. _requires.js is the precondition gate (requireEngine, requireRedis, summary); a missing precondition is a hard failure unless named in RELAY_TEST_SKIP. _relay-server.js boots a real relay.js on a free port, which is what the five route-*.test.js suites drive over HTTP. |
test.yml, split across two jobs (section 4) |
relay/crypto/ |
7 test files, the algorithm registry and byte-format gates. | test.yml job relay-crypto-tests, step "Relay crypto suite" |
admin/ |
server.js, an Express 5 operator panel: admin console, developer gate, signup lock, user and plan management, CLI proxy. lib/ is 20 modules, public/ the served SPA. |
test.yml job admin-unit-tests; tests/static-sanity.sh checks 1, 2, 4 and 6 target admin/server.js by name |
frontend/ |
The static site: ~45 HTML pages plus js/ (41 extracted scripts), auth/, docs/, help/, billing/, security/, assets/, pkg/ (the wasm build). Served under script-src 'self', so no inline JS anywhere. |
csp-inline-check.yml; test.yml job frontend-cache-bust; sign-e2e.yml (11 browser suites); product-heartbeat.yml |
bron-seo/ |
Two Python generators. build_sitemap.py regenerates frontend/sitemap.xml from what is on disk; apply_seo_head.py stamps the head-level SEO contract idempotently. Both take --check. |
Nothing directly: no Python runs in CI at all. Its output is guarded by tests/seo-contract.test.mjs. Run the generator, do not hand-edit the output. |
scripts/directie/ |
signalen.py: a no-model status meter. Asks GitHub over gh and production over curl, turns each answer into red/orange/green with the command it measured with. |
Nothing. Documented in docs/directie.md. |
scripts/heartbeat/ |
run.mjs and its lib: the four hourly production proofs (surface, parasend, parasign-receipt, parasign-public-sign). Every step runs even after one fails, on purpose: a dead page must never hide a dead signer. |
Its logic by tests/heartbeat-lib.test.mjs (runs on every push); its execution by heartbeat.yml (hourly) |
deploy/ |
DEPLOY-3.1.md is the runbook; deploy-3.1.sh executes it phase by phase and refuses to continue when a measurement disagrees. .env.example is the canonical env inventory (81 variables, names and purposes, no values). Plus the three nginx configs and ops/backup-*.sh. |
tests/env-documented.test.mjs (every process.env name must be documented there, and nothing documented that no code reads); test.yml job shell-syntax (bash -n); test.yml job deploy-dryrun runs tests/deploy-3.1-dryrun.test.sh on every push and pull request, with no server and no secrets. |
tests/ |
27 node:test .mjs suites (16 node-only, 11 browser), plus static-sanity.sh, auth-smoke.sh, e2e-auth-flow.sh and the deploy dry-run self-test. |
test.yml (node-only set), sign-e2e.yml (browser set), product-heartbeat.yml. Of the .sh files, deploy-3.1-dryrun.test.sh runs in CI (test.yml job deploy-dryrun) and static-sanity.sh does too (test.yml job static-sanity, thirteen checks, no secrets and no server); auth-smoke.sh and e2e-auth-flow.sh need a live target and run at deploy time only. |
Selection of browser vs node-only suites is done by what a file imports, never
by a hand-kept name list (.github/workflows/test.yml:290-296,
.github/workflows/sign-e2e.yml). Write a new browser suite and it is picked up
the day you write it.
State this honestly rather than discover it. bron-seo/*.py,
scripts/directie/signalen.py, tests/test_paramant_admin_paths.py,
tests/deploy-3.1-dryrun.test.sh, build.sh, and
relay/test/koop-pad-intentie.test.mjs (11 cases, missed because the unit job
globs test/*.test.js and this is the only .mjs in that directory).
Node must satisfy engines: ">=22 <25" (package.json:5-7); .nvmrc says 24,
which is what every workflow uses. Docker is needed for the route suites only.
Measured below on Node v22.22.2, npm 10.9.7, Docker 29.6.1.
relay/package.json declares @paramant/core as
file:../../paramant-core/crates/paramant-core-node: a sibling repo that is
not in this monorepo. npm ci in relay/ does not fail without it, it just
leaves a dangling symlink, and then relay.js crashes on boot because
relay/crypto/bootstrap.js loads the ML-KEM-768 / ML-DSA-65 impls eagerly and
there is no pure-JS fallback (AGENTS.md:56-70). Clone both as siblings:
mkdir paramant && cd paramant
git clone https://github.com/Apolloccrypt/paramant-relay.git
git clone --no-checkout https://github.com/Apolloccrypt/paramant-core.gitThen build the binding at the commit CI pins
(PARAMANT_CORE_SHA in .github/workflows/test.yml). Needs cmake, ninja,
clang and a Rust toolchain:
cd paramant-core
git checkout b90b3c52af5c3c30053766bfa108924aff4f9eec
cargo build -p paramant-core-node --release
cp target/release/libparamant_core_node.so crates/paramant-core-node/index.nodeMeasured: 29 seconds with a warm cargo registry. Cold, budget several minutes; liboqs is a C build.
You only need this for the crypto, engine, integration and route suites. The unit suites and the whole frontend side run without it.
cd paramant-relay
npm ci # root: ~109 packages
cd relay && npm install # npm install, not ci: this is what links @paramant/core
cd ../admin && npm cinpm install in relay/ is what CI uses too
(.github/workflows/test.yml, step "Install relay deps (incl. file-linked
@paramant/core)"). Root and admin report a handful of advisories today; that is
known, not something you introduced.
Committed hooks do not activate themselves (AGENTS.md:24-30). Once, per clone:
git config core.hooksPath .githooksWithout this, .githooks/pre-push never runs the commit-style scan and you find
out about section 5 from a red PR instead of from your own machine.
Run these in order. The commands are the ones CI runs, copied from
.github/workflows/test.yml; the counts are what a clean clone printed on
2026-09-02.
Static gates (no dependencies, seconds):
bash tests/static-sanity.sh
# 12 numbered checks, ends with "PASS (all hard checks clear)". ~1.5s
# Same command as test.yml job static-sanity runs on every push and pull request.
npx --yes eslint@9 .
# silent on success. ~5s
scripts/check-cache-bust.sh
# cache-bust guard: OK - 340 local css/js/mjs link(s), all carry a single consistent ?v=
scripts/check-csp-inline.sh
# OK: geen inline JS onder de CSP
find . -name '*.sh' -not -path '*/node_modules/*' -not -path '*/.git/*' -print0 \
| xargs -0 -n1 bash -n
# silent on successRelay unit suite (no native deps, no redis):
cd relay
RELAY_TEST_SKIP=redis node --test --test-reporter=tap $(ls test/*.test.js | grep -vE \
'inbound-hash-verify|deep-health-gate|billing-stance-boot|parasign-sandbox|parasign-open-api-e2e|parasign-envelope-index|parasign-signs-quota|route-')
# # tests 199 # pass 199 # fail 0 ~1.5sThe exclusion list is not arbitrary and the reporter is not decoration: see section 6.
Admin unit suite:
cd admin && node --test test/*.test.js
# # tests 67 # pass 67 # fail 0 ~1m50sFour of those suites boot a real admin/server.js (login-http,
login-timing, redis-outage, ratelimit-ttl), so this job now needs a redis
and the admin's own node_modules, which the CI job deliberately did not
install. login-timing additionally boots a real relay.js for its
backup-code case, because the oracle there is ten argon2 verifications and a
stub would be measuring the stub. That needs @paramant/core, which relay.js
refuses to start without, so the admin CI job declares it (ADMIN_TEST_SKIP=relay)
and the relay-crypto-tests job, the one that builds the binding, runs that
suite for real and fails if it skips there. They declare their precondition the way the relay suites do: without it
the run fails by name, unless the runner says ADMIN_TEST_SKIP=redis.
Most of those two minutes are deliberate waiting rather than work.
login-timing measures at 0, 12 and 20 prior failures, and by design an answer
at twenty failures is held for 2.25 seconds; its backup-code case is held for at
least 1.5 seconds per request, because that floor has to sit above ten argon2
verifications. Every request past the failure threshold also has to carry a real
2^18 proof-of-work, which login-http pays as well.
Root integration suites (node builtins plus the root deps, no browser):
node --test $(node scripts/browser-suites.mjs --no-browser)
# # tests 190 # pass 188 # skipped 2 # fail 0 ~1.2sThe two skips are the external-link checks in tests/links.test.mjs; they need
CHECK_EXTERNAL_LINKS=1 and run hourly against production, not on a pull
request.
Crypto suite (needs the sibling binding from 3.1):
cd relay && node --test $(find crypto -name '*.test.js')
# # tests 144 # pass 144 # fail 0 ~21sParaSign engine suites and the boot integration suites (binding, no redis):
cd relay
RELAY_TEST_SKIP=redis node --test test/parasign-sandbox.test.js \
test/parasign-sandbox-live-guard.test.js test/parasign-open-api-e2e.test.js \
test/parasign-envelope-index.test.js
# # tests 4 # pass 4
node --test test/inbound-hash-verify.test.js test/deep-health-gate.test.js \
test/billing-stance-boot.test.js
# # tests 11 # pass 11 ~1.3s (each of these boots a real relay.js)These three boot a real relay.js and wait for it to become healthy, so they are
the only suites here that are sensitive to a loaded machine: one run in ten failed
on a busy laptop and the other nine were clean. Rerun before you go looking for a
cause. Every other suite in this section was deterministic across repeated runs.
Route suites (need redis; the envelope store is redis-only, so without a
server every /v2/envelopes route answers 503 and the ParaSign lifecycle is
untestable):
docker run --rm -d --name paramant-test-redis -p 127.0.0.1:6399:6379 redis:7.4.8-alpine
> De binding op `127.0.0.1` is bewust. Zonder dat voorvoegsel publiceert Docker
> de poort op alle netwerkkaarten en staat er een redis zonder wachtwoord open
> voor iedereen op hetzelfde netwerk.
cd relay
REDIS_URL=redis://127.0.0.1:6399 \
node --test --test-reporter=tap test/route-*.test.js test/parasign-signs-quota.test.js
# # tests 91 # pass 91 # fail 0 ~21s
docker rm -f paramant-test-redisSame image tag docker-compose.yml runs in production. If 6399 is taken, pick
another port and set REDIS_URL to match: every suite reads
process.env.REDIS_URL first. Watch out for
relay/test/parasign-envelope-index.test.js, whose built-in default is 6396,
not 6399; CI overrides it with REDIS_URL and so should you.
Browser suites (Playwright, Chromium only):
npx playwright install chromium
node tests/sign-full.test.mjs
# 33/33 passed ~4s
node --test $(node scripts/browser-suites.mjs --browser | grep -vE 'sign-full|product-heartbeat')
# # tests 12 # pass 12 # fail 0 ~7sYou do not need to serve the frontend yourself. Each browser suite starts its own
http.createServer over frontend/ on an OS-assigned port and stubs /api, so
there is no backend and no fixed port to remember. PLAYWRIGHT_CHROMIUM_PATH
overrides the binary if you would rather use a system Chrome.
Deploy dry run (touches nothing, needs no ssh key):
bash deploy/deploy-3.1.sh --dry-run --preflight-only
# ends with "PREFLIGHT ONLY: phases 0 and 1 done." and "Warnings: 0", exit 0--dry-run prints every remote heredoc as [dry-run] > instead of running it,
prints every assertion as SKIP assert (dry-run), and never calls gh, ssh,
docker or rsync (deploy/deploy-3.1.sh:254-257, :281-330). It does write one
local file, deploy/logs/deploy-3.1-<TS>.log, which is gitignored. Read the
whole nine-phase printout with --dry-run alone.
For development use bash scripts/dev-local.sh, not docker compose up
(AGENTS.md:47-54). It boots relay-health on :3001, admin on :4200 and a
single-origin proxy on http://localhost:8080, prints a passkey setup URL, and
tails to /tmp/paramant-dev-*.log. Redis must be reachable: redis-cli ping
returns PONG. The docker compose up -d path in README.md is the
production-like one.
test.yml (workflow name tests) has seven jobs, all on Node 24. The split
matters because it is what the exclusion list in 3.4 encodes:
relay-unit-testsinstalls nothing for the relay. Pure-JS units only. It thennpm cis the root deps and runs the 16 node-only root suites.relay-crypto-testsbuilds@paramant/corefrom the pinned sibling commit and has aredis:7.4.8-alpineservice on 6399. It runs the crypto suite, the four ParaSign engine suites, the three boot-integration suites, and the five route suites plusparasign-signs-quota.admin-unit-tests,shell-syntax,undefined-names,frontend-cache-bust,gitleaksare one command each.
sign-e2e.yml, product-heartbeat.yml and csp-inline-check.yml are
deliberately not path-gated: they are required status checks, and a skipped
required check never reports, so a PR would wait on it forever. Only the two
Docker workflows are path-gated, both on relay/**.
Both the unit job and the route step pipe TAP through tee and then grep for
# <suite>: SKIPPED - 0 checks ran, the line summary() in
relay/test/_requires.js prints. The expected set is empty in both. A suite
that runs and asserts nothing fails the job by name. This exists because four
ParaSign suites once reported "ok 24" while asserting nothing at all for weeks.
The instruction in the error message is the rule: fix the suite, do not add it
to a list (.github/workflows/test.yml:183-186).
These are hard. Each one has a gate that will find you.
No AI attribution, anywhere. Commit messages, PR bodies and GitHub comments
are all in Mick's name: no co-author trailer in any spelling, no generated-with
line, no AI attribution at all. Also no em-dashes (U+2014), no emoji, and never
a real person or company name in code, comments, commits, tests or branch names
(use acct_demo, demo@example.com, signer Demo, company Acme).
Source: AGENTS.md:8-16. Gate: scripts/check-commit-style.sh, which scans both
the commit message and the added + diff lines, and runs from both
tests/static-sanity.sh and .githooks/pre-push. Default tooling adds these
trailers for you; turn them off before your first commit.
static-sanity is thirteen checks, and check 10 is the style guard.
tests/static-sanity.sh runs thirteen numbered checks: syntax, redisClient
initialisation, TOTP helpers (warn), unsafe req.body access (warn), orphan code
(warn), /request-key still returning 410, DID-auth replay protection, the
open-mode envelope signer binding, installer release pinning, check 10, the
commit and GitHub style guard delegating to check-commit-style.sh, check 11,
the test-scope guard delegating to check-test-declarations.sh, check 12,
the opt-in on the tracked brand screenshots, and check 13, the known-flaky
register delegating to check-flaky-register.sh. Exit code is the number of
hard failures.
It runs in CI as test.yml job static-sanity, on every push to main and every
pull request. That is new. Until then it ran in no workflow at all: its only
callers were the pre-commit hook and deploy.sh:25, security-posture.yml
named it in a comment and nowhere else, and every "static-sanity PASS" on record
came from somebody's laptop. If your hook is not installed, or you committed
with --no-verify, the pull request now says so.
Which commits check 10 reads depends on where it runs, and it has to. Locally
and on a push it is the last commit, so on a branch with a non-conforming
commit checks 1 to 9 pass and the script still exits 1;
deploy/DEPLOY-3.1.md:197-204 documents that this state does not block a deploy.
On a pull request the checkout is refs/pull/N/merge, whose HEAD is a merge
commit with no diff of its own, so the script scans
origin/$GITHUB_BASE_REF..HEAD instead: the commits the pull request adds. That
is why the job checks out with fetch-depth: 0, and why it prints which range it
scanned. PARAMANT_STYLE_RANGE overrides the choice. Do not rewrite history to
make it green.
Every claim on the site is tied to a test. The rule, from
docs/brand/messaging.md:14-18: a sentence may only go on the site if it is
already true on the site or in the code, and there is a test that fails when it
stops being true. The inventory of all 38 public pages and their pins is
docs/site-claims.md; the enforcing suite is tests/site-claims.test.mjs, which
reads the number or constant from the source that enforces it and then checks the
page says the same thing, and asserts the absence of claims the repository
cannot show. tests/ui-truthfulness.test.mjs carries the rest. Adding a claim is
a five-step procedure written down at docs/brand/messaging.md:500-513.
Pins hang off tiers.js and the code, never off another page.
relay/lib/tiers.js is the single declared source of truth for plans and limits;
relay.js turns its rows into a 402 and a 413. Tests read tiers.js and compare
the page to it. The reason is written in the test itself
(relay/test/pricing-page.test.js:494-497): pinning one page to another only
proves the two agree, it cannot catch both being wrong together, which is exactly
what happened when /pricing and index.html carried the same wrong upload figure.
The free plan is called Community. Not Free, not Starter, not Basic
(docs/brand/messaging.md:85-87). tests/ui-truthfulness.test.mjs:415-447 runs
ten regex shapes that can only be a plan name over every .html under
frontend/, recursively, with exactly two exempted files, each with its reason
in the test.
Two more that fail a first PR. Every local css/js link must carry exactly one
?v= cache-bust, and the same asset may never appear with two different values
(scripts/check-cache-bust.sh; nginx serves those paths immutable, max-age=1y).
And the site runs under script-src 'self', so any inline <script> or inline
event handler is dead in the browser, not merely ugly; only
application/ld+json is exempt (scripts/check-csp-inline.sh). Signup and login
were down for a week over this in July 2026.
Nav and footer are stamped by frontend/apply-nav.py. Do not hand-edit them,
and do not touch frontend/js/nav-auth.js (docs/brand/messaging.md:512-513).
From CONTRIBUTING.md: one fix per PR, include a test where applicable,
update CHANGELOG.md under [Unreleased], English only in code and comments.
Never open a public issue for a vulnerability.
Not duplicated here. Read the two documents.
docs/RELEASE.mdis the release process. The rule it exists to enforce: a release is a tag, the tag builds the image, and production runs that image. Rootpackage.jsonis the only version source of truth; every other copy is hand-carried and policed bytests/version-consistency.test.mjs. The CHANGELOG section gets written fromgit log v3.0.0..origin/mainbefore any version number moves. Lines 94-111 are the local CI mirror, which is the same set of commands as section 3.4 above.deploy/DEPLOY-3.1.mdis the production runbook, anddeploy/deploy-3.1.shexecutes it. Preflight is read-only; rollback is--rollback <TS>. The commit the production checkout must stand on before phase 3 is a parameter, not a constant:PARAMANT_EXPECTED_HEADwins, otherwise thedeployed-headmarker the previous deploy wrote on the server, otherwise the runbook's starting commit. That is what makes a second deploy, and a resume of a failed one, possible at all.
Two layout facts that catch everyone once: /opt/paramant-relay is the checkout
and the compose directory, /home/paramant/app is the nginx docroot and only a
copy. docs/RUNBOOK-DEPLOY-3.0.0.md describes the older reality (server builds
from git pull, no registry) and is superseded.
Two workflows with similar names, and they are not the same thing.
.github/workflows/heartbeat.ymlis the hourly production alarm.cron: '17 * * * *'plus manual dispatch, no push trigger. It is gated on a GitHub repository variable:if: github.event_name == 'workflow_dispatch' || vars.HEARTBEAT_ENABLED == 'true'(heartbeat.yml:56). It runsscripts/heartbeat/run.mjsagainst production, then the browser heartbeat suite, then the external link check, and uploads the evidence directory. On red it opens or reopens one issue titledHeartbeat roodwith labelheartbeatand comments on it; on green it closes every open one. The procedure for switching it on is indocs/heartbeat.md:124-171: set the secrets, dispatch it by hand once, and only thengh variable set HEARTBEAT_ENABLED --body true. Note thattests/site-claims.test.mjs:385asserts the flag in both directions, so flipping it off fails the/slaclaim test until the page is rewritten..github/workflows/product-heartbeat.ymlis the pull-request gate against your checkout, not against production.
A step that records no proof is failed by runStep even if nothing threw, and a
HEARTBEAT_DRY_RUN=1 run can never be green by construction. There is
deliberately no skip path: a missing secret is a named hard failure.
scripts/directie/signalen.py is the wider status meter, and it uses no
language model. It asks GitHub over gh and production over curl and turns
each answer into red, orange or green with the command it measured with in
details.bron, so every line is reproducible.
python3 scripts/directie/signalen.py --tekstExit 0 means nothing red, 1 means something red, 2 means the script itself broke.
Without a gh login the GitHub signals go orange rather than failing. Thresholds
are constants at the top of the file, each with its reason. Full table in
docs/directie.md. One staleness warning: docs/directie.md:53-59 still
describes the three canaries as steps in product-heartbeat.yml; they moved to
heartbeat.yml on 2026-09-02.
One Hetzner VM in Germany, 116.203.86.81, reached as root over SSH
(deploy/DEPLOY-3.1.md:12, deploy/deploy-3.1.sh:36). On it: five sector relays
(relay-main :3000, relay-health :3001, relay-finance :3002, relay-legal
:3003, relay-iot :3004), the admin panel on :4200, redis, all bound to
127.0.0.1, with system nginx as the TLS terminator in front. Six hostnames
resolve there. There is no staging environment (docs/RELEASE.md:238-239).
Where things live on that host, paths only:
| Path | What |
|---|---|
/opt/paramant-relay |
the checkout and docker-compose.yml |
/opt/paramant-relay/.env |
all runtime secrets, mode 600 |
/home/paramant/app |
the frontend docroot nginx serves (a copy) |
/home/paramant/backups/ |
rollback manifests, .env copies, docroot tarballs, the deployed-head marker |
/data/relay-identity.json |
the relay's own signing key, per relay volume, 0600 |
/root/.config/paramant-backup/key.txt |
the age key for the encrypted backups, root only |
/etc/nginx/sites-enabled/paramant-{public,live}.conf |
the two confs a deploy edits, and only those two |
Off the host: the production SSH key is ~/.ssh/paramant_prod_claude on the NUC,
and the deploy scripts hard-fail anywhere else with "run this from the NUC". The
heartbeat credentials are GitHub Actions repository secrets.
Who has the key: one person. deploy/DEPLOY-3.1.md:12-14 says it plainly:
Mick runs the deploy by hand over SSH, from the NUC, where the production key
lives. There is no second holder documented anywhere in this repo, and the backup
age key is root-only on the box the backups sit on. If you are the second
developer reading this, that is the first thing to change, not the last.
The hygiene rule the scripts follow and you must too: environment variables are
reported as empty or set, prefix <first 5 chars>, never as values. No remote
block runs under set -x. No read of .env reaches stdout.
The relay crashes on boot and you did not touch it. @paramant/core is
missing. npm ci in relay/ leaves a dangling symlink rather than failing, so
the error surfaces much later as "relay did not become healthy in time". Build
the sibling, section 3.1.
A test reporter that quietly disarms a gate. Node's default --test reporter
for a non-TTY changed from tap to spec between Node 20 and Node 24, and spec
drops the # prefix. Two CI gates parse those TAP diagnostic lines, so under
spec the grep matches nothing, the silent set comes back empty, and the gate
passes for the wrong reason. That is why --test-reporter=tap is written out
in both jobs rather than inherited. Keep it when you copy a command. Its sibling:
node:test counts a skip as a pass, so ok N - name # SKIP reason lands in
# pass (docs/heartbeat.md:8-33).
You add a variable to .env and nothing happens. There is no env_file in
the compose stack, anywhere. .env only substitutes ${VAR} into
docker-compose.yml, so a variable without a line in the x-relay-env anchor
never reaches a container (docker-compose.yml:62-64). This bit for real: the
four receipt variables from #342 had no line, so setting them in .env alone
would have done nothing. Second half of the trap: containers pick up a new value
only when recreated, not on restart.
gitleaks version, and why it is pinned. GITLEAKS_VERSION: "8.28.0" in
test.yml, with the action itself pinned to a commit SHA rather than a tag. The
action's own default resolved to 8.24.3, which parses a [[allowlists]] table in
.gitleaks.toml and then ignores it: no error, no warning. Measured on the same
branch with the same config, 8.24.3 reports 2 findings and 8.28.0 reports 0. A
config that is silently skipped is worse than one that fails to load.
Two green PRs that merge into a red main. This is the one that will cost you
an afternoon, and it happened on 2026-09-02. Two pull requests touched
relay/test/pricing-page.test.js and tests/ui-truthfulness.test.mjs at the same
time and each added a top-level const near the end of the file. Each PR was
green, because CI ran each branch against the base it was cut from, not against
the merged result. Merged, the files carried const tiers twice and
const pricingVisible twice: a hard SyntaxError that takes the whole file down
before a single assertion runs, so the suite does not fail one check, it fails to
load. eslint does not save you here either, because the config carries exactly one
rule and it is no-undef, not no-redeclare.
What to do about it: if your branch and another open PR touch the same test file,
rebase on main and run the suite once more before you ask for a merge, and read
a red tests job on main as everyone's problem rather than the last merger's.
The files most exposed are the long append-only ones, pricing-page.test.js,
ui-truthfulness.test.mjs and site-claims.test.mjs, where the convention is to
add a new block at the bottom with its own helpers. #359 cleaned up both
collisions and put its new blocks in function scope, which is the durable fix: a
block that owns its names cannot collide with the next one.
A PR whose checks do not run. Do not read an empty check list as green. A PR
that has been open a while with zero checks is an explicit orange signal in
scripts/directie/signalen.py:255-258, with the instruction attached: find out
why it has no checks before you judge it. The usual causes are a merge state that
never triggered a run, and a path-gated workflow that was skipped, which is why
the three required checks in this repo are deliberately not path-gated. Two
documented cousins of the same failure: a skipped required check never reports,
so the PR waits forever; and an in_progress run is not a pass, which is how two
still-running runs were waved through the old deploy gate on 2026-09-02
(deploy/deploy-3.1.sh:120-125).
Tests that fail locally on purpose. tests/auth-smoke.sh asserts production
semantics (/request-key returns 410, the captcha shape, the signup route);
several of its checks fail against the local dev stack and that is expected, not
a regression (AGENTS.md:79-95). Same file: the EACCES ... mkdir '/data'
warning in dev is harmless, account email validation rejects dotless domains so
dev@localhost returns a harmless 400 (use alice@example.com), and the browser
apps hardcode the production relay hosts so they will not target your local relay
without code edits.
A healthcheck that returns 200 over a broken container.
docs/regression-watchlist.md records three bug classes that reached production
that way: a changed response shape, orphan code throwing a ReferenceError
inside a handler, and an uninitialised redisClient. An HTTP ping cannot tell a
working container from one that crashes on specific paths. That is what
tests/e2e-auth-flow.sh is for.
eslint here has exactly one rule. no-undef, scoped to relay/ and admin/
server code (eslint.config.mjs). It is not a style linter and it will not catch
a duplicate declaration in a test file. The comment above the job says why the
one rule exists: an hourly setInterval swept a Map that no longer existed and
took production down 425 times before anyone looked.
AGENTS.mdfor the commit and PR rules and the environment caveats.docs/PROJECT-STATUS.mdis now a pointer table, not a snapshot. Follow it.docs/adrs/for the 19 architecture decision records: the reasoning is written down, which is rare.deploy/.env.examplefor every environment variable the code reads, with a purpose, a default and the file that reads it.tests/env-documented.test.mjskeeps it honest in both directions.tests/README.mdfor where a new check belongs: static ones go instatic-sanity.sh, live ones inauth-smoke.sh.docs/cross-repo-coordination.mdfor how this repo andparamant-coremove together.