igneum/docs/plans/release-0.3.6.md

27 KiB

Igneum Miner 0.3.6: the testnet adoption, staged 5 October 2026

Prepared by the integration agent on the morning of 5 October 2026 after the owner's decision. Nothing was deployed, published or merged to master. The app and document work sits on branch testnet-adopt (pushed to origin), worktree /Users/joshm/Projects/igneum-wt-adopt; the node work sits on the fork branch release-0.3.6 (local only; the fork has no remote), worktree vendor/igneum-node-036. The cut is one command (section 4) after the inputs push of section 3, which must come first.

0. The decision (owner, 5 October 2026)

The proposed testnet identity (docs/testnet/README.md) and the proposed fee floors and prover-gas table (docs/analysis/base-fee-floor.md, spec 05 section 5.11) are ADOPTED as proposed. The three documents now say so; the fork's FeeParams::CALIBRATED_V1 and TESTNET_PARAMS are unchanged from the proposal.

One rule the adoption added, so the live devnet is never forked by a node update: the devnet keeps its fee rules until a height switch says otherwise (section 5).

1. What is in

1a. The app repository (branch testnet-adopt, from origin/master 2054ae3 = the 0.3.5 cut)

Merged Branch head What it carries Conflicts and how they were resolved
origin/testnet-prep beed743 (3 commits over e22068f) docs/testnet/README.md, docs/analysis/base-fee-floor.md, spec 05 section 5.11, docs/plans/history-rewrite.md (G14, not acted on here), G13 signed build inputs (app/igneum-app/src/inputs.rs, igneum-ota-sign sign-inputs / verify-inputs, packaging/windows/push-inputs.sh, inputs-manifest.sh, node-source.pin, test-inputs-signing.sh, fetch-ci-artifacts.sh --sign-manifest, .github/workflows/windows.yml), tools/ci/check-workflow-shell.mjs, the testnet terms on the download section, site/wallet.html, the litepaper's app paragraph, the fast-time profile's fees object four generated site files. site/index.html: the dev-fee sentence of 0.3.5 kept, testnet-prep's #testnet-terms card and wallet link kept (both sides of the download section); the inlined journey block is regenerated. site/litepaper.html: 0.3.5's two-paragraph dev-fee text kept (it is the fuller one; testnet-prep's one-sentence version dropped), testnet-prep's MetaMask and Igneum Wallet paragraph kept. site/journey.json: 0.3.5's log kept (the feed is the newest 40 entries; testnet-prep's older entries had already aged out of it). site/sitemap.xml: both /miners and /wallet. Then node site/build.mjs

On top of the merge, in the same branch:

Change Where
"proposed" to "adopted 5 October 2026" with the sign-off noted, and the per-network fee rule written in docs/testnet/README.md, docs/analysis/base-fee-floor.md, docs/spec/05-fees-and-economics.md (section 5.10 and the parameter table)
fees_v1_activation_daa: 0 added to the 60x fast-time profile (the fork's fast_time_60x_file_is_the_devnet_at_60x test wants every override field present) infra/fast-time/override-60x.json
this file docs/plans/release-0.3.6.md

1b. The node fork (branch release-0.3.6 in vendor/igneum-node-036, from release-0.3.5 20139145)

Fork merge Head What it carries Conflicts
testnet-params (worktree vendor/igneum-node-testnet, forked from finality-fixes 6aa69a45) 11e86144 consensus/core/src/fees.rs (FeeParams, PgasTable, CALIBRATED_V1, PROTOTYPE), Params.fees and the override file's fees object, the testnet identity (igneum-testnet-1, chain id 4462, ports 268xx, the frozen genesis, FinalityParams::MAINNET, every switch at 0), the override file refused on the testnet, the execution layer reading the installed set (B_p, S_p, the intrinsic, modexp, the floors) consensus/core/src/config/params.rs: both methods kept (apply_env_pow_schedule from G12 and install_fee_params); the M31 comment on max_coinbase_payload_len kept. kaspad/src/daemon.rs: the 0.3.5 start-up block kept (environment schedule on devnet and simnet, install_pow_schedule, the digest line) and install_fee_params with its own print added after it

On top of the merge, the fee height switch (section 5), in the same branch:

Change Where
FeeParams::DEVNET and SIMNET = PROTOTYPE; TESTNET and MAINNET = CALIBRATED_V1. FeeSchedule { base, v1_activation_daa } with at(daa); install_fee_params(base, switch); fee_params_at(daa) replaces fee_params() consensus/core/src/fees.rs
Params.fees_v1_activation_daa (devnet and simnet u64::MAX, testnet and mainnet 0), OverrideParams.fees_v1_activation_daa, the digest rule of section 5, four new or changed tests consensus/core/src/config/params.rs
the switch and a fees object printed from the override file; the installed schedule printed with the network kaspad/src/daemon.rs
shard_proving_gas_budget_at(daa) consensus/core/src/proving.rs
every reader takes a DAA score; execute_segment picks the set by the block's DAA score and raises the carried base fees to its floors; the inspector carries the block's pgas table (modexp reads it); next_base_fee takes floor and denominator; the pool's add and select and every RPC quote use the tip's DAA score plus one; the shard plan uses the segment's; one new executor test igneum/exec/src/{config,executor,pgas,pool,proving,rpc,service}.rs

On top of that, two more fork commits:

Commit What
cf369022 DEV_FEE_ADDRESS = 0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126, the release dev-fee payout address (testnet and mainnet; the project lead, 5 October 2026, EIP-55 checksum verified, on file at ~/.config/igneum/dev-fee-release.json); DEV_FEE_ADDRESS_DEVNET unchanged; the placeholder test replaced by the_release_address_pays_the_fee_outside_the_devnet (cargo test -p igneum-miner dev_fee: 5 passed). docs/design/miner-dev-fee.md updated on this branch
2b6d23ef the merge of fork miner-latency (section 2), the final tip of release-0.3.6

1c. What is out

Branch Why
the app repository's local miner-latency (dd47ff3, worktree igneum-wt-latency: tools/miner-latency/run.mjs, docs/plans/miner-latency.md, two bench-log entries; never pushed to origin) not part of this task; it conflicts with testnet-adopt only in docs/bench-log.md (both appended entries) and can be merged at any time. The harness was run from that worktree for section 2

2. The miner-latency decision: IN

Fork miner-latency (0f88b6d6, vendor/igneum-node-latency, one commit over devnet-v4: the NewBlockTemplate subscription in the miner, node-side template prewarm, workers switching without draining the batch, --poll-templates and --job-ms) was merged on a side branch first (release-0.3.6-latency, worktree vendor/igneum-node-036-lat, c07093ce) and landed on release-0.3.6 (2b6d23ef) only after both gates passed on the merged tree:

Gate Command Result
Miner tests cargo test -p igneum-miner on c07093ce ok: 15 passed, 0 failed
3-node fast-time run node tools/miner-latency/run.mjs --mode subscribe (from igneum-wt-latency, IGNEUMD and IGNEUM_MINER = the release build of c07093ce in vendor/igneum-node/target-036-lat/release, under the run lock, 08:11 to 08:16Z) 3 nodes up (peers 2/1/1 throughout), 3 CPU miners at 2 threads for 295 s: 82 + 81 + 80 blocks accepted, 0 rejected; chain blocks 228, merged blue 15, merged red 0, tips mean 1.03 max 2, sinks agree; 236 to 241 template switches per miner, notified p50 14 to 16 ms, template p50 20 to 24 ms, switched p50 46 to 52 ms (p90 118 to 130 ms, max 194 to 372 ms); node prewarm 52 to 56 builds a minute, mean 3 to 4.5 ms; 1 to 2 stale submits per miner; no error, panic or WORKER FAULT line in any miner log
The dev fee through the new feed node tools/dev-fee/run.mjs --secs 180 on the same binaries (two nodes, three stub miners, two at the default fee and one at --dev-fee 0, then igneum-miner payouts; 08:17 to 08:20Z) pass: miner-a found=327 fee=4, miner-b found=301 fee=4, control miner-c found=309 fee=0 (0 dev-fee block lines); payouts on both nodes: 938 blocks, the devnet dev address 8 blocks = the miners' counters, 1.27% of the fee-paying miners' blocks against the 1% expectation

The conflicts (13 hunks in igneum/miner/src/main.rs, the 0.3.5 dev fee and X21 mismatch guard against the latency rewrite of the template path) were resolved by keeping both sides: the feed takes the dev fee and requests one template in 100 with it, as the 0.3.5 fetcher did; the continuous CPU loop carries the fee through Work.dev_fee and counts fee blocks; the found path runs check_found into the mismatch guard; WorkEngine::Real holds the day-keyed EpochRef of M30. The 3-node run above showed fee=0 on every miner: 243 blocks found at 1 in 100 templates expects about 2.4 fee blocks, and zero has a probability of about 9%, so the dev-fee harness was run as the third gate; it showed the fee riding through the new feed (8 of 8 fee blocks on chain, the control at 0).

3. Test results, with the command

Every build ran through the main checkout's lock, /Users/joshm/Projects/igneum/tools/lock/with-lock.sh build, at nice -n 19 with -j 4. The fork's builds used CARGO_TARGET_DIR=vendor/igneum-node/target-036, an APFS clone of target-release (48 s to clone). The rustup cargo (1.99.0) on ~/.cargo/bin.

3a. The app repository (worktree igneum-wt-adopt)

Check Command Result
Site node site/build.mjs built: bench, journey (40 entries), index, litepaper, live, evidence, wallet, 404, miners (6 rows)
Links node tools/ci/link-check.mjs 324 internal links across 8 pages, 0 broken
Workflow shell node tools/ci/check-workflow-shell.mjs self-test fires on the fixture; 11 run blocks in 2 workflows, 25 .ps1 files, 0 findings
Signed inputs packaging/windows/test-inputs-signing.sh with igneum-ota-sign built from this tree (cargo build --release -j 4 --bin igneum-ota-sign, 8 s with the cloned target) 16 passed, 0 failed: the four positive cases, the ten refusals (changed zip byte, changed manifest byte, changed unpacked file, unlisted file, missing file, wrong pin, short pin, another key, the embedded key against the throwaway signature, the two sign-time refusals), and the Mac's real OTA key verified by the key compiled into the app
Shell parses bash -n on push-inputs.sh, fetch-ci-artifacts.sh, inputs-manifest.sh, test-inputs-signing.sh ok
JavaScript parses node --check on tools/ci/check-workflow-shell.mjs, tools/ci/link-check.mjs, site/build.mjs ok
PowerShell parse rule (no $var: inside double quotes) the 0.3.5 scanner over every tracked .ps1 and the PowerShell strings in ota.rs, jobrun.rs, jobbuild.rs the 3 baseline hits of 0.3.5 only (two comments in check-ps51.ps1 that quote the rule; ota.rs line 1190, the Mac helper's bash); testnet-prep changed no .ps1

3b. The node fork (worktree vendor/igneum-node-036)

Fork tip Command Result
a11455e7 (testnet-params merged, the fee switch) cargo test -p kaspa-consensus-core with IGNEUM_FAST_TIME_FILE=<igneum-wt-adopt>/infra/fast-time/override-60x.json (the fast-time test wants every override field; the main checkout's file predates fees) ok: 101 passed, 0 failed, 2 ignored (lib) + 7 (db_compat); among them igneum_testnet_identity, override_params_carry_the_fees, override_params_carry_the_fees_v1_activation, consensus_digest_keeps_the_0_3_5_value_until_the_fee_switch_is_set (pins 9409dedac4bf... for the devnet params), consensus_digest_covers_every_consensus_field_and_nothing_else (the fee switch in its list), test_genesis_hashes, fast_time_60x_file_is_the_devnet_at_60x; 07:41Z
a11455e7 cargo test -p igneum-exec ok: 11 passed, 0 failed; among them the_fee_switch_meters_by_the_block_daa_score (a block below the switch meters 200 intrinsic pgas and keeps its base fees; the block at the switch meters 300 and its base fees jump to the v1 floors) and the bomb tests re-sized to fit both tables; 07:43Z
a11455e7 cargo test -p kaspa-consensus --features igneum-pow -- finality ok: 8 passed, 0 failed (92 filtered), 125.9 s
a11455e7 cargo test -p kaspa-consensus --features igneum-pow -- difficulty ok: 15 passed, 0 failed
a11455e7 cargo test -p igneum-miner ok: 12 passed, 0 failed (the guard and dev-fee tests)
c07093ce (miner-latency merged on the side branch release-0.3.6-latency, worktree vendor/igneum-node-036-lat, CARGO_TARGET_DIR=target-036-lat) cargo test -p igneum-miner ok: 15 passed, 0 failed (the 12 above and job_size_follows_the_target_ms, cpu_found_nonce_is_matched_to_its_own_work, worker_found_hash_is_matched_to_its_own_template)
c07093ce 3-node fast-time run (section 2) pass (section 2)
cf369022 (the release dev-fee address) cargo test -p igneum-miner dev_fee ok: 5 passed
2b6d23ef (the final tip: cf369022 plus the latency merge) cargo test -p igneum-miner ok: 15 passed, 0 failed

Not built here: the release binaries for the three platforms (section 4b).

4. The morning cut, in order

4a. The signed inputs come first (G13)

The workflow and push-inputs.sh changed as a pair. From this tree on, .github/workflows/windows.yml fetches payload-inputs.json.sig from the downloads host and refuses to build until igneum-ota-sign verify-inputs embedded accepts the manifest's signature, the zip's sha256, every unpacked file and the node commit pinned in packaging/windows/node-source.pin in the commit it builds. The downloads host holds no signature today (0.3.5 pushed an unsigned inputs.json and a bare .sha256), and the pin in this tree still reads 6aa69a45 (the 0.3.4 node, written by the old script). So the FIRST push of the new workflow would fail at "payload inputs" unless the inputs are pushed from this tree before the workflow runs:

cd /Users/joshm/Projects/igneum-wt-adopt
gh auth switch --user igneum-labs
packaging/windows/test-inputs-signing.sh                              # 16 of 16, with the real key at the end
IGNEUM_NODE_SRC=vendor/igneum-node-036 IGNEUM_WIN_RELEASE=<the 0.3.6 Windows release folder> \
  packaging/windows/push-inputs.sh --deploy                           # signs payload-inputs.json, writes node-source.pin
git add packaging/windows/node-source.pin && git commit -m "inputs: pin node <commit> for 0.3.6"

What the workflow needs, and where it comes from:

Need Source
DL_TOKEN repository secret already set for 0.3.5 (gh secret set DL_TOKEN < ~/.config/igneum/dl-token); the workflow fetches the zip, the manifest and the signature with it
the embedded public key compiled into the app (app/igneum-app/src/manifest.rs); igneum-ota-sign embedded prints it; push-inputs.sh refuses to sign if ~/.config/igneum/ota-signing-key.pub is not that key
~/.config/igneum/ota-signing-key (0600) and .pub the OTA key the apps already trust; signs the manifest on the Mac
packaging/windows/node-source.pin written by push-inputs.sh, committed with the push; the runner compares it with the manifest's node_source_commit
the Windows node exes IGNEUM_WIN_RELEASE names the folder with igneumd.exe and igneum-miner.exe built from the fork commit the pin names (the 0.3.6 cross-build; proto-cuda/windows-node/cross-build.sh as in 0.3.5)

Then the update manifest is signed only on request: packaging/windows/fetch-ci-artifacts.sh --sign-manifest <run-id> --deploy after the run is green, which re-verifies the run's inputs artifact against the key and the pin at the run's commit before publish-manifest.sh runs.

4b. The merge and the ship

From the MAIN checkout, on master, after 4a's pin commit is on testnet-adopt:

cd /Users/joshm/Projects/igneum
gh auth switch --user igneum-labs
git fetch origin && git checkout master && git pull --ff-only
git merge --ff-only origin/testnet-adopt          # testnet-adopt contains master 2054ae3
node tools/ship-app.mjs --check                    # 0.3.5 in all 6 files
node tools/ship-app.mjs 0.3.6 \
  --node vendor/igneum-node-036 \
  --win-release <the 0.3.6 Windows release folder> \
  --mac-release <the 0.3.6 Mac release folder> \
  --notes "Testnet identity and the adopted fee table in the node (devnet unchanged until the fee switch), signed build inputs"

The 0.3.6 binaries are NOT built yet (this plan stops at the suites; section 3b says what was and was not built). Build them from fork 2b6d23ef as 0.3.5's section 4a did: Mac cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow with CARGO_TARGET_DIR=vendor/igneum-node/target-036 (the release build in target-036-lat is of c07093ce, one commit short: no release dev-fee address); Windows through proto-cuda/windows-node/cross-build.sh; Linux through infra/cross/build-linux.sh. The push to master triggers windows.yml, which now verifies the inputs of 4a before it builds the app.

5. The devnet rollout of the fee floor: a height switch

The live devnet runs the prototype fee set (B_p 30,000,000, 1 gwei floors, intrinsic 200). A 0.3.6 node on the devnet runs exactly that until told otherwise, so 0.3.5 and 0.3.6 nodes build and accept the same segments through the whole rolling update. The adopted set reaches the devnet by fees_v1_activation_daa, the same pattern as difficulty_v2_activation_daa (33,000, 4 October 2026):

Step What Check
1 Ship 0.3.6 (section 4). Every node updates with the switch at its default, never. The consensus digest does not move (the fee fields enter the digest only once the switch or the set leaves the 0.3.5 state), so 0.3.5 and 0.3.6 peers keep handshaking Consensus params digest: 9409dedac4bf... on a node with no override file, the same line 0.3.5 printed; the live devnet nodes (override file with the difficulty and finality switches) print their own unchanged value
2 Wait until every devnet node is on 0.3.6: the console's Machines card and the user agents in the peer list (igneumd/2.1.0-<0.3.6 fork commit>) no -20139145 user agent left
3 Pick the switch height: a DAA score at least 24 hours ahead (86,400 blocks), on a round number, announced in the engineering log and the app's update note
4 Publish the switch with the other switches in the update manifest's consensus.override (packaging/ota/publish-manifest.sh --override '{"difficulty_v2_activation_daa": 33000, ..., "fees_v1_activation_daa": N}' --deploy), and add the line to every hand-run node's override file. The digest moves the moment a node restarts with it; a node that has not restarted is refused by those that have, which is why the restart must sweep every node before N Calibrated v1 fees from the override file: ... from DAA score N in every node's start-up log; one digest across the peer list
5 At N: the first chain block at or above N meters with v1 (intrinsic 300, B_p 120,000, S_p 30,000, modexp 10 + 1 per 10 bytes) and its base fees jump to 100 gwei per gas and 10,000 gwei per pgas. Nothing resets; history stays igneum_getBudgets returns provingGasLimit 120,000 and the two base fees at the floors; eth_getBlockByNumber of the switch block shows provingBaseFeePerGas 0x9184e72a000
6 The prover's mirror of the table (proving/igneum-prove/core/src/config.rs, pgas.rs: prototype values at b7fca5a0) must carry v1 before N, or every shard statement after N differs from the node's plan. This is the one change this plan does not make; it re-cuts every fixture at S_p 30,000 and changes the guest program id the fixtures and the guest id in docs/analysis/base-fee-floor.md section 4

Why a switch and not a fresh chain: the devnet has 12 cloud nodes, three PCs and outside machines on it, with the difficulty v2 rollout as the precedent that a height switch over a live chain works. Why not the override file's fees object: that changes the rules of every block including the past, so a node restarted with it could not replay its own history.

6. Open after this plan

Item State
--netsuffix default 1 under --testnet one line in kaspad/src/args.rs, not done; igneumd --testnet --netsuffix 1 until then
the testnet genesis message reads proposed, not final (the proposal's words, now part of the hashed genesis 52a3e6a9...). Adopted as computed. If the owner wants the words changed, it is one print_genesis_hashes run, new hash and merkle root in genesis.rs, test_genesis_hashes and the README, and it must happen before the first public node, never after
the prover's table mirror section 5 step 6
seed nodes, public RPC, the explorer, the app's testnet build docs/testnet/README.md section 5, unchanged
G14 history rewrite docs/plans/history-rewrite.md, merged as a plan, not acted on
the inputs push (4a) and the 0.3.6 binaries not done here

7. What the proving activation taught us (written on master, 5 October 2026, 08:45 BST)

Written 5 October 2026, 08:45 BST, while proving v0 went live on the devnet at DAA 84,100.

Must ship in 0.3.6

Item Why Where
The app sets IGNEUM_PROOF_VERIFIER for its node On 5 October no node on the network ran a verifier (verifier: Off on every app node and the seed). Spec 7.7 item 4: a producer without a verifier never includes a record, so proofs were stored and never paid. The Mac app was relaunched by hand with the variable; Windows apps cannot be. app/igneum-app/src/engine.rs node spawn: Mac and Linux point at the shipped igneum-prove-host; Windows points at a small wrapper exe that runs the WSL host (wsl -d Ubuntu-24.04 -- /opt/igneum/igneum-prove-host --mode verify ... with wslpath for the proof file). Until the wrapper exists, devnet Windows nodes run IGNEUM_PROOF_VERIFY=trust (devnet only, never testnet).
Windows payload ships wsl2/bin/igneum-prove-host and igneum-prove-export The app's probe looks there first; today a PC needs the 20-minute WSL setup or a hand-installed /opt/igneum. The Linux binaries come from infra/cross/build-linux.sh (CUDA feature needs the CUDA toolchain in the cross image, else ship the CPU build and let setup-wsl.sh build the CUDA one). packaging/windows/make-payload.sh, Igneum-Miner.iss
The proving tile shows the verifier state A node that relays but never includes should say so on the tile and on /live. app/igneum-app/src/state.rs, site/api/live.mjs (verifier is already in the observer report)
Rotation phase 2 Branch rotation-2 (5317305): --dl-both, tools/logs.mjs --rotation, fresh-repo script. Plan: docs/plans/rotation-phase-2.md.
Testnet parameters behind fees_v1_activation_daa Branch testnet-prep and the fork's testnet-params (agent in progress).

Operational lessons from the activation (5 October 2026)

  • Consensus override changes must land on every node at once: a hand node restarted early with a different proving_v0_activation_daa was refused by every peer (digest handshake) and sat isolated at a lower height for 20 minutes. Order that works: publish the manifest override, update-now to every app, wait for every app node to log the new parameters, then restart the hand nodes and the seed with the same file.
  • scratchpad/restart-hand-nodes.sh died silently after igneumd --version (the 0.3.5 binary exits 1 after printing) under set -e; the restart it reported never happened. Every restart script ends by printing the new pids and their start times.
  • Switching proving on needs no app restart: POST <app.url>/api/prove {"on":true} (the job prove-on-pc2-84100 does this after installing the CUDA host into /opt/igneum for the app's WSL user).

Instant jobs (5 October 2026)

the project lead: "why is it taking so long for pc2 and pc1s tasks to spin up? can we speed it up?". Before 0.3.6 every app polled igneum-jobs.json every 10 minutes (CHECK_EVERY_S = 600), so a job published from the Mac waited up to 10 minutes on each PC. The PCs have no inbound ports and one miner is off the LAN, so the fix is a wake signal the app pulls over an outbound connection. Branch job-wake.

Piece What it does Where
Wake endpoint GET /wake?since=<stamp>: public (the apps hold no token), 30 a minute per IP, held up to 45 s, answers {stamp, at, added, changed, held_ms} the moment the stored stamp differs from since, else the unchanged stamp at the deadline. POST /r/<token>/wake {stamp, added} (the relay's auth) records a stamp. Rows in Neon relay_wake, created by the first POST. maxDuration 60 s. relay/api/wake.mjs, relay/lib/wake.mjs, relay/vercel.json, test relay/test/wake.test.mjs (fake database and clock; in CI's site job)
App waker One thread per app long-polls the endpoint with the last stamp; a changed stamp sends Event::Wake and the engine fetches the jobs file at once (signature check unchanged). First reply seeds the stamp. Backoff 5, 15, 60 s while the relay is unreachable, 10 s floor between requests, a full hold when the relay has no stamp yet. One log line per wake, one when it falls back, one when it recovers. IGNEUM_APP_JOBS_WAKE_URL overrides the URL (set and empty: no waker). app/igneum-app/src/jobrun.rs (WakeState, wake_loop), unit tests for the stamp and backoff logic
Fallback poll 2 minutes (was 10), the first poll still 40 to 60 s after start. An unchanged file is no longer logged on every poll. jobrun.rs CHECK_EVERY_S
Publisher After the live file verifies, --deploy POSTs the stamp (published_at plus 8 hex of the file's sha256) and the added id; the token goes in a header file, never on the command line; prints "woke the apps" or a one-line warning. packaging/ota/publish-jobs.sh wake_apps
Status node tools/jobs.mjs status shows "woken +N s after the publish" per machine for a job a publish added. tools/jobs.mjs

Expected latency, publish to job start: the relay re-reads the stamp every 2 s inside the hold, the app's curl returns at once, the engine fetches the file (two small downloads) and starts the job on its next tick. About 3 to 6 s when the app is mid-hold, plus up to 10 s if the app was inside its floor after an earlier reply; the 2-minute poll is the ceiling when the relay is down. Numbers below are measured, not estimated.

Machine Publish to started_at Date Source
PC 1 (ae432dc7) TODO (measure with node tools/jobs.mjs status after the first 0.3.6 job)
PC 2 (1ccfe586) TODO
Mac TODO

Not yet done on this branch: the relay deploy (relay/, by the owner; the /wake route and the 60 s maxDuration go live with it, the relay_wake table appears on the first POST), the first live publish, and the Windows curl path of the long-poll (curl.exe 8.x in System32; the 58 s --max-time was reviewed, not run).