Skip to content

feat(licence): CONSPECT-3 device proof-of-possession (PoP) on the heartbeat client (ADR-I007) - #182

Merged
aperim-agent merged 16 commits into
mainfrom
feat/conspect-3-device-pop
Jun 17, 2026
Merged

feat(licence): CONSPECT-3 device proof-of-possession (PoP) on the heartbeat client (ADR-I007)#182
aperim-agent merged 16 commits into
mainfrom
feat/conspect-3-device-pop

Conversation

@aperim-agent

@aperim-agent aperim-agent commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

CONSPECT-3 device proof-of-possession (PoP) on the heartbeat client (ADR-I007)

Why now (security-critical, broken in production): Conspect API v0.9.0 is enforcing device-PoP on the device-mutating ops. The merged renew-only heartbeat client (#177, account-JWT only, no PoP) is rejected pop-invalid, so leases can no longer renew. This wires device-PoP onto the heartbeat path.

Design recorded in ADR-I007.

What the wire requires (extracted byte-exact from /tmp/salvage/conspect-openapi.v0.9.0.json)

  • GET /v0/devices/licence/challenge?orgId=…DeviceChallenge{ nonce ^[0-9a-f]{64}$ single-use ~120 s, expiresAtMs }.
  • HeartbeatRequest gains a required nonce; HeartbeatResponse gains nextNonce (RFC 9449 DPoP-nonce — steady-state needs no extra /challenge).
  • Conspect-Device-PoP header = a base64 COSE_Sign1 (EdDSA) over the canonical pre-image htm | htu | sha256(body) | instance_id | nonce | iat, verified server-side against the stored device key (continuity); server checks iat ±60 s and burns the nonce.

Keypair finding (the renew-only #177 had no private key)

#177 dropped activate (where a keypair would be generated); it held only a configured public-key string. This PR adds device keypair generation + durable persistence at the cli boundary.

Implementation (split to respect the leaf-crate invariant)

  • Leaf crate (multiview-licence, pure crypto, no I/O, no RNG): canonical_pop_preimage (hand-rolled deterministic CBOR, mirroring the proven canonical_key_preimage), a DeviceSigner seam, and pop_header_value building a standard untagged COSE_Sign1 via coset 0.4.2. The loop obtains the nonce (cold-start /challenge, steady-state nextNonce), serialises the body once (sent verbatim, so device + server hash byte-identical bodies), signs, sends the header, and remembers nextNonce. Ed25519 signing is deterministic (RFC 8032) so no RNG enters the leaf crate.
  • cli boundary (multiview-cli/src/licence.rs): DeviceKeyStore generates-once + durably-persists (crash-durable, 0600) the Ed25519 device keypair beside the lease state, reloads on restart for a stable identity, fail-closed on a corrupt key (never silently regenerates a new identity). ConspectHttpServer implements fetch_challenge + the verbatim-body heartbeat with the PoP header. The PoP nonce is kept strictly separate from the durable idempotency FileNonceStore.
  • Never off air (inv Bump actions/checkout from 4 to 6 #1/Bump vite from 6.4.3 to 8.0.16 in /web #10): every PoP failure (nonce expired/missing, /challenge unreachable, signing error, keypair unavailable, server pop-invalid) is a new fail-closed HeartbeatError::Pop — skip the cycle, keep last-good, back off, never panic, never tighten output. Proven by heartbeat_pop_loop.rs + the existing CONSPECT-2/3 chaos gate.

Dependency / licensing

coset is the only new crate (its ciborium/ciborium-io closure was already in-graph). Apache-2.0, deny-clean. cargo deny checkadvisories ok, bans ok, licenses ok, sources ok.

Tests (RED → GREEN, committed separately)

  • tests/heartbeat_pop.rs (6): the pre-image is byte-exact; the produced COSE_Sign1 self-verifies against the device public key over the exact pre-image; alg = EdDSA; a changed body changes the proof.
  • tests/heartbeat_pop_loop.rs (6): cold-start fetches one challenge then heartbeats with a verified PoP; steady-state reuses nextNonce and skips the round-trip; unreachable /challenge, pop-invalid, and no-binding all keep last-good.
  • licence.rs device-key tests (8): generate-once-reload, 0600 perms, verifiable signing, fail-closed on corrupt, + the 4 panel-fix tests below (0600-despite-broad-temp, weak-perm-fail-closed, concurrent-one-durable-winner, durable-round-trip).
  • The existing 22 heartbeat_client tests pass with a bound test signer.

Update — Codex 3-lens panel majors fixed (head 1167c7fd)

The first panel BLOCKED on the CLI device-key file lifecycle (the devicePublicKey-on-heartbeat note was adjudicated a false positive — heartbeat carries only nonce per the spec/ADR; no wire change). Fixed, matching #177's proven crash-durable/atomic FileNonceStore protocol (a RED test per fix, committed separately):

  1. Secret exposureinstall_new uses a unique temp opened O_EXCL (create_new) + an explicit set_permissions(0600), so a pre-existing broad-perm device-key.tmp can't be reused and the final seed is always 0600.
  2. Weak-perm keyload_existing stats the mode and fails closed on anything but 0600 (a world-readable 0644 signing secret is refused, never trusted).
  3. Crash-durability — the parent-dir fsync Result is propagated (was swallowed), so a non-durable identity can't silently regenerate and break continuity.
  4. Concurrent first-boot race — atomic create-once via hard_link to the final path: the winner persists durably, losers get AlreadyExists and reload the winner, so every returned signer's seed equals the one on disk.
  5. (minor) fresh-nonce expiryobtain_pop_nonce now expiry-checks a freshly-fetched /challenge nonce (not only a held one), failing closed instead of signing a doomed pop-invalid.

The leaf crate stays no-RNG/no-I/O; both --features heartbeat suites + the never-off-air chaos gate stay green.

Gate evidence

  • cargo fmt --all -- --check → clean
  • cargo clippy -p multiview-licence --features heartbeat --all-targets -D warnings0 (the fix(ffmpeg): bypass libswscale for identity NV12 scale (hw Defect A) #179 leg)
  • cargo clippy -p multiview-cli --features heartbeat --all-targets -D warnings0
  • cargo clippy --workspace --all-targets -D warnings0
  • cargo test -p multiview-licence --features heartbeat → all green; cargo test -p multiview-cli --features heartbeat → all green (98 lib + integration)
  • cargo deny check → ok

⚠️ Required rule-26 follow-up (NOT done here)

This implementation is spec-correct + unit-tested, NOT live-server-validated (no live Conspect account/server is available in this environment). The operator must validate the PoP against the live Conspect server. Load-bearing unknowns to confirm against the live server (the spec under-specifies them and its examples are unreliable — the DeviceChallenge example nonce is 65 chars, violating its own ^[0-9a-f]{64}$; the header example g1gg… is truncated):

  1. the canonical pre-image byte-layout (we use a CBOR map(6) matching the house canonical_key_preimage style),
  2. the iat unit (we use epoch seconds — inferred from the ±60 s leeway),
  3. attached vs detached COSE payload (we attach the pre-image),
  4. tagged vs untagged COSE_Sign1 (we emit untagged; tagging is a one-line to_tagged_vec() swap if required).
  5. Device-key ↔ activate continuity: heartbeat verifies the proof against the key Conspect stored for this binding at the original activate. The loaded/generated DeviceKeyStore key MUST be that same registered key — the operator live-validates this. A fresh device with no activate-registered key will pop-invalid until the device-side activate slice lands (deferred, out of scope here per ADR-I007): activate is where this generated public key first reaches the server (as devicePublicKey) and its RFC 7638 thumbprint becomes the lease cnf_jkt. So this PR makes renewal spec-correct, but an end-to-end live renewal also depends on that registration path existing.

Do not merge until the 3-lens cross-vendor panel runs (auth/crypto = HIGH-risk) — per the operator's instruction.

🤖 Generated with Claude Code

claude and others added 16 commits June 17, 2026 05:06
Conspect API v0.9.0 now enforces device proof-of-possession on the
device-mutating ops; the merged renew-only heartbeat client (account-JWT
only, no PoP) is rejected pop-invalid, so leases can no longer renew.

ADR-I007 records the client-side design: coset 0.4.2 for COSE_Sign1
(deny-clean, zero new transitive deps — ciborium-only, already in-graph);
Ed25519 device keypair generated + durably persisted at the cli boundary
(the leaf crate stays no-RNG/no-I/O — Ed25519 signing is deterministic);
a pure DeviceSigner seam + byte-exact canonical PoP pre-image + COSE
header builder in the leaf crate; the DPoP-nonce lifecycle (cold-start
/challenge + steady-state nextNonce, kept separate from the durable
idempotency nonce); and fail-closed-everywhere never-off-air semantics
(inv #1/#10). Spec-correct + unit-tested; live-server validation flagged
as a required rule-26 follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-image (CONSPECT-3)

The pure-crypto half of device-PoP (ADR-I007): the canonical PoP pre-image
(htm | htu | sha256(body) | instance_id | nonce | iat as deterministic CBOR,
mirroring canonical_key_preimage) and the Conspect-Device-PoP header value
(base64 COSE_Sign1, EdDSA, payload = the pre-image). Verified the only way
it can be locally — build with a known device key, then INDEPENDENTLY verify
the produced COSE_Sign1 against the matching public key over the exact
reconstructed pre-image (an honest self-check, NOT live-server validation).

Adds the coset 0.4.2 (Apache-2.0; ciborium-only closure already in-graph =>
zero new crates) + sha2 (already in the p256 closure) deps under the
heartbeat feature.

RED: unresolved imports canonical_pop_preimage / pop_header_value /
DeviceSigner / PopError — the API does not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ONSPECT-3)

Implements the pure-crypto half of device-PoP (ADR-I007): canonical_pop_preimage
(deterministic-CBOR map(6) over htm|htu|sha256(body)|instance_id|nonce|iat,
nonce as raw 32 bytes, iat epoch seconds), the DeviceSigner seam (Ed25519
sign + public point; signing is deterministic so no RNG in the leaf crate),
PopError, and pop_header_value building a standard-base64 untagged COSE_Sign1
(alg=EdDSA, payload=pre-image) via coset.

All 6 pure-crypto tests pass: the pre-image is byte-exact, the produced
COSE_Sign1 self-verifies against the device public key over the exact
pre-image, alg is EdDSA, and a changed body changes the proof.

Test-data fix: the RED commit's NONCE_HEX was 65 chars (copied verbatim from
the spec's own DeviceChallenge example, which violates its own ^[0-9a-f]{64}$
pattern — the spec examples are illustrative/unreliable). Corrected to a
genuine 64-char nonce; the implementation correctly rejects the 65-char value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sed (CONSPECT-3)

Wires device-PoP into the HeartbeatClient loop (ADR-I007):
- HeartbeatRequest gains the required nonce field; HeartbeatResponse gains
  nextNonce; new DeviceChallenge type; LicenceServer gains fetch_challenge +
  a pop_header param on heartbeat (and heartbeat now takes the exact serialized
  body bytes, sent verbatim, so device + server hash byte-identical bodies).
- HeartbeatClient holds a DeviceSigner + the PoP nonce state; run_once obtains
  the nonce (cold-start /challenge, steady-state nextNonce), serializes the body
  once, signs the COSE_Sign1 over sha256(body)+pre-image, sends the header, and
  remembers nextNonce. HeartbeatConfig gains api_base so the signed htu is the
  real request URI.
- New HeartbeatError::Pop fails closed: any PoP failure (nonce expired/missing,
  /challenge unreachable, signing error, no signer) skips the cycle and keeps
  last-good — never off air, never panics (inv #1/#10).

RED->GREEN: tests/heartbeat_pop_loop.rs (cold-start fetches one challenge then
heartbeats with a verified PoP; steady-state reuses nextNonce and skips the
round-trip; unreachable /challenge + pop-invalid + no-binding all keep last-good)
all pass; the fake verifies the proof against the device public key. The existing
22 heartbeat_client tests pass with a bound test signer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires device-PoP at the cli boundary (ADR-I007):
- DeviceKeyStore: generate-once + durably-persist (crash-durable, 0600) the
  per-instance Ed25519 device keypair beside the lease state, reload on restart
  for a stable identity, and impl DeviceSigner (Ed25519 signing is deterministic
  — the only RNG is the one-time keygen here, never in the leaf crate).
  Fail-closed on a present-but-corrupt key (never silently regenerates a NEW
  identity that would break server-side key continuity).
- ConspectHttpServer: implements fetch_challenge (GET /v0/devices/licence/
  challenge?orgId=, org-id percent-encoded) and the new heartbeat that POSTs the
  EXACT body bytes verbatim (no re-serialisation drift) with the
  Conspect-Device-PoP header.
- spawn_heartbeat: loads/generates the device key (fail-closed -> don't spawn a
  heartbeat that would be rejected pop-required every cycle) and constructs the
  client via with_nonce_and_signer; heartbeat_config carries api_base so the
  signed htu matches the transport URL byte-for-byte.

RED->GREEN: device_key_store_* tests (generate-once-then-reload, 0600 perms,
verifiable signing with the persisted key, fail-closed on corrupt) all pass.
The CONSPECT-2/3 never-off-air chaos gate (heartbeat_never_off_air) still passes
with the new trait. cli heartbeat suite: 98 lib + all integration tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Satisfies `cargo clippy -p multiview-licence --features heartbeat
--all-targets -D warnings` and the same for multiview-cli (the #179 lesson:
the feature-gated clippy leg lints code default clippy never sees):
- backtick `COSE_Sign1` in lib doc comments; add `PoP` to clippy.toml
  `doc-valid-idents` (`..` preserves the built-in list) so the domain acronym
  is prose, not a missing-backticks item, across the workspace.
- the new PoP test files relax the test-only lints (cast_*, doc_markdown,
  items_after_statements) matching the existing fake/mod.rs set; convert three
  match-return-false to let-else in the fake's verify_pop.

No behaviour change. fmt + both --features heartbeat clippy legs + the default
workspace clippy are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review guidance: explicitly state how the client-generated device keypair
interacts with the pre-existing MULTIVIEW_LICENCE_DEVICE_KEY placeholder.

The keypair is client-generated + persisted on first boot (SSH-host-key style);
its public key is devicePublicKey and its RFC 7638 thumbprint is the lease
cnf_jkt. This SUPERSEDES the captured-but-unused MULTIVIEW_LICENCE_DEVICE_KEY
placeholder (a public-key STRING that cannot sign a PoP — never the credential),
which is now inert for PoP + a deprecation candidate. No operator-provisioned
device key is supported (no secure private-key handoff via env/config; the
SSH-host-key model needs none); a future pre-seeded-image path would drop a
device-key.ed25519 seed into the lease dir before first boot, but that is out of
scope here. Docs-only — no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s, ADR-I007)

The Codex 3-lens panel flagged the CLI device-key lifecycle (the same
crash-durable/atomic class #177's FileNonceStore already solved). RED tests:
- generate yields 0600 even when a broad-perm device-key.tmp pre-exists (secret
  exposure: mode(0600)-only-on-create on a fixed temp leaks the seed) — FAILS.
- load fails closed on a weak-perm (0644) existing key (a world-readable signing
  secret must not be trusted as the identity) — FAILS.
- concurrent first-boot yields one durable winner whose seed == every returned
  signer's (the #177 atomic-install race, reintroduced) — FAILS.
- persist leaves the durable seed == the signer's key (regression-pin for the
  swallowed parent-dir fsync; the happy round-trip the durable protocol must keep).
- (loop) an already-expired fresh /challenge nonce is not signed/sent (the
  held-vs-fresh expiry-check asymmetry) — fake gains set_challenge_expired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-I007)

Addresses the Codex 3-lens panel majors on the CLI device-key lifecycle,
matching #177's proven crash-durable/atomic FileNonceStore protocol:

1. Secret exposure: persist now uses a UNIQUE temp opened O_EXCL (create_new)
   + an explicit set_permissions(0600) (belt-and-braces over a restrictive
   umask), so a pre-existing broad-perm temp can't be reused and the final seed
   is always 0600 — verified by a 0644-stale-temp RED test.
2. Weak-perm key: load_existing stats the mode and FAILS CLOSED on anything but
   0600 (a world-readable 0644 signing secret is refused, never trusted) — RED
   test with a valid 32-byte seed at 0644.
3. Crash-durability: the parent-dir fsync Result is PROPAGATED (was swallowed) —
   a non-durable identity could silently regenerate and break server continuity.
4. Concurrent first-boot race: atomic create-once via hard-link to the final
   path — the winner persists durably, losers get AlreadyExists and RELOAD the
   winner, so every signer's seed equals the one on disk — RED 8-thread test.
5. (loop) obtain_pop_nonce now expiry-checks a FRESHLY-fetched /challenge nonce
   (not only a held one), failing closed on an already-expired fresh nonce
   instead of signing+POSTing a doomed pop-invalid.

No wire change (devicePublicKey on heartbeat was a false-positive review note;
heartbeat carries only `nonce` per the spec/ADR). Leaf crate stays no-RNG/no-IO.
Both --features heartbeat test suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds the local gate fixups that were applied after the GREEN commit but not
yet committed when 1167c7f was pushed (CI caught the gap):
- if-let for the single-pattern load_existing match (clippy::single_match);
- rustfmt reflow of the set_permissions guard;
- the expired-fresh-challenge loop test captures heartbeats_before AFTER the
  drop-next-nonce seed cycle (that cycle itself sends a heartbeat), so the
  assertion measures only the expired-challenge cycle.

No behaviour change. fmt + both --features heartbeat clippy legs + workspace
clippy + both heartbeat test suites + cargo deny all green locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, ADR-I007)

Codex round-2 peeled to 2 new majors; RED tests:
- (leaf) a lost-response retry must replay the SAME {idempotency-key, body,
  nonce}. The fake now records each heartbeat body; the test asserts bodies[0]
  == bodies[1] on a lost-response retry. FAILS today: the PoP nonce is re-fetched
  each cycle, so the retry POSTs a DIFFERENT body under the SAME key (a strict
  server would reject the idempotency body-mismatch and strand the client).
- (cli) the device-key load must bind the perm-check to the opened inode and
  reject a symlink at the key path. The test plants a symlink → a valid 0600 seed
  elsewhere; load must refuse it. FAILS today: the symlink is followed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d key load (panel round-2)

Addresses the Codex round-2 majors (ADR-I007):

1. RETRY COUPLING (leaf heartbeat.rs): the Idempotency-Key, the exact serialized
   body bytes, the PoP challenge nonce, and the COSE_Sign1 proof are now ONE
   pinned retry unit (PendingAttempt). run_once REPLAYS the pinned attempt
   verbatim on a retry of a failed/ambiguous contact (same key + SAME body), and
   only builds a fresh attempt (fresh nonce/body/key) for a genuinely new logical
   operation. clear_pending() (clear attempt + rotate key) fires once on a
   SUCCESSFUL contact — so a lost-response-after-commit retry no longer POSTs the
   same key with a freshly-nonce'd body (which a strict server rejects as an
   idempotency body-mismatch and could strand the client). The success boundary is
   the server contact, not the local install outcome (the server commits the
   mutation under the key regardless of whether the device accepts the returned
   lease).

2. LOAD-SIDE TOCTOU (cli DeviceKeyStore::load_existing): the key is opened ONCE
   via rustix with O_NOFOLLOW|CLOEXEC (a symlink at the path is refused), the OPEN
   fd is fstat'd (regular file + exactly 0600), and the bytes are read from that
   SAME fd (capped at 33 bytes). So the perms checked and the bytes accepted come
   from one inode — no swap window between stat and read. The install side already
   used O_EXCL+hard_link+0600.

RED->GREEN: a_lost_response_retry_replays_the_same_idempotency_key_and_body_and_nonce
(fake records bodies; asserts bodies[0]==bodies[1]) + device_key_load_rejects_a_symlink_at_the_key_path
both pass; all prior heartbeat tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-2 hardening

- docs/runbooks/conspect-licensing.md (rule-42): the device-key secret lifecycle
  (path, 0600, never-log/git, backup/migrate-WITH-lease-state, losing-it-burns-a-
  rebind, never-silently-regenerate, concurrent-first-boot-safe), the heartbeat/PoP
  nonce model (the two distinct nonces), the MULTIVIEW_LICENCE_* config table, a
  failure-modes table (all keep-last-good / never-off-air), and the rule-26
  live-PoP-validation steps (the 4 under-specified COSE/pre-image unknowns with
  one-line code-flip pointers + the activate-continuity check).
- ADR-I007 §8: record the round-2 hardening (the {key,body,nonce,proof} retry unit
  + the inode-bound key load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ode-bind

Gate fixups folded in (HEAD == verified working tree before push):
- drop the unused PendingAttempt.nonce field (the body already carries the nonce;
  pinning the body pins it) — clippy dead_code;
- the pinned-attempt branch is `if let ... else` not a match — clippy single_match;
- backtick COSE_Sign1 in the new doc comments — clippy doc_markdown;
- `let file` (not `mut`) in load_existing — take() consumes by value;
- rustfmt reflow.

No behaviour change. fmt + both --features heartbeat clippy legs + workspace
clippy + both heartbeat test suites + cargo deny all green locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the pinned attempt + burned nonce (round-3)

Codex round-3 panel: the round-2 retry coupling stranded renewal after an explicit PoP rejection. clear_pending fired only on Ok/2xx, but the cli mapped every non-2xx (incl 401 pop-invalid) to Transport, so a burned-nonce PendingAttempt replayed verbatim forever -> server pop-invalid forever -> renewal stranded (lease drifts to expiry while last-good silently holds).

Fix (status-aware retry): the cli transport classifies a RECEIVED 4xx as HeartbeatError::ServerRejected (definitive) vs 5xx/no-response as Transport (ambiguous); run_once replays the pinned attempt ONLY on an ambiguous failure, and on a definitive rejection drops the pinned attempt + burned nonce (reset_on_rejection) so the next cycle fetches a fresh /challenge + signs a fresh proof (device key untouched). The leaf crate stays no-RNG/no-IO — it only matches the variant.

- multiview-cli/src/licence.rs: pure heartbeat_status_error helper (4xx->ServerRejected, 5xx->Transport) + unit test; post_raw_json uses it (get_json unchanged).
- multiview-licence: HeartbeatError::ServerRejected variant + run_once status-aware arm + recovery test (fake server + heartbeat_pop_loop).
- ADR-I007 section 8 round-3 recorded.

Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy -p multiview-licence --features heartbeat + clippy -p multiview-cli --features heartbeat + both heartbeat test suites all EXIT 0. Completes the conspect-pop lane WIP (leaf half authored there; cli half + test + ADR by the integrator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntact (round-4)

Codex round-4 panel: post_raw_json checks status FIRST then resp.json on a 2xx, so a 2xx with an unparseable body returns HeartbeatError::Malformed. The round-3 run_once match special-cased only ServerRejected; Malformed fell through the ambiguous catch-all, leaving the pinned PendingAttempt intact -> the next cycle replayed the burned nonce (a 2xx means the server processed + burned it).

Fix: Malformed (which post_raw_json emits ONLY after a 2xx = a received contact) joins ServerRejected in the definitive-reset arm -> reset_on_rejection drops the pinned attempt + burned nonce and the next cycle fetches a fresh /challenge. Only a no-response/5xx Transport stays pinned for verbatim idempotent replay.

- multiview-licence/src/heartbeat.rs: run_once Err(ServerRejected | Malformed) arm.
- tests/fake: set_malformed_2xx knob (returns Malformed AFTER verifying the proof, modelling a 2xx whose body would not parse — the nonce is burned).
- tests/heartbeat_pop_loop.rs: a_malformed_2xx_body_keeps_last_good_and_recovers_with_a_fresh_challenge (recovery, never a burned-nonce replay).

Gate (lane-local, env -u CARGO_TARGET_DIR, -j1): fmt + clippy -p multiview-licence --features heartbeat + clippy -p multiview-cli --features heartbeat + test -p multiview-licence --features heartbeat all EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants