Skip to content

cherry-pick: Publish TLS state transactionally so a failed rebuild never downgrades below last-good (#2384 → v5.2) - #2982

Merged
kriszyp merged 1 commit into
v5.2from
cherry-pick/v5.2/pr-2384
Oct 2, 2026
Merged

kriszyp merged 1 commit into
v5.2from
cherry-pick/v5.2/pr-2384

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Cherry-pick of #2384 onto v5.2: a TLS rebuild pass never publishes a state worse than the one being served. updateTLS builds the whole replacement state into pass-local candidates and reconciles the live maps only after the pass completes; a record that is still in the table but fails to build keeps every live entry it owns and its default candidacy, so a cert renewal whose hdb_certificate write reaches a worker before the matching on-disk key no longer drops that worker to the self-signed default until the 5-minute re-read heals it. A failed pass arms a self-retry with per-signature backoff (1.5 s doubling to 5 min), and loadAndWatch's mtime latch now means "last successfully applied": it rolls back on a synchronous throw or a rejected callback promise, so the periodic poll can heal a lost table write instead of deduplicating it forever.

Why now: on 5.2 today, a renewal whose key lands on a worker before the matching cert makes that worker serve the self-signed certificate until the next 5-minute re-read (#1394); production nodes served the wrong certificate for days with nothing retrying (#2382). The fails-on-base run below reproduces exactly that on unmodified v5.2.

For the human reviewer

  1. Design assessment: no new design. This is a backport of a merged, reviewed change; the invariant it enforces (a rebuild pass never publishes below last-good, and only deleting a record drops its contexts) is unchanged from main. The planning gate was not run for that reason, matching the task brief.
  2. Two conflict resolutions in security/keys.ts, no extra main commit ported. Five main-side keys.ts commits sit between the v5.2 branch point and Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384; only two of them reach the squash commit's parent (Canonicalize watch paths so a Windows 8.3 short path cannot abort the process #2309, watch-path canonicalization with polling fallback; and the IPv6 node-identity change to getHost). Neither is a dependency of Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384's hunks, and neither is pulled in:
  3. Workflow hunk kept. v5.2's integration-tests.yml has the same job shape as main, so HARPER_TEST_REQUIRE_FILE_WATCHERS: '1' is added to the integration job env, making cert-key-reload fail instead of skip when a file change never reaches hdb_certificate on Linux runners. Pushing a workflow file needs a token with workflow scope, which is why cherry-pick-patch.yml could not open this PR itself.
  4. Branch rebuild caveat. cherry-pick-patch.yml rebuilds cherry-pick/<rel>/pr-<N> branches from the release branch on PR pushes and at merge, without rerere; expect the keys.ts resolution to need re-applying if that workflow touches this branch. Test this branch by dispatching integration-tests.yml / unit-test.yml with --ref cherry-pick/v5.2/pr-2384, not through the cherry-pick workflow.
  5. Related root-cause issue, out of scope here: Cert renewal reaches workers as two separate reloads (key from disk, cert via table), so every renewal builds a mismatched pair #2978.
  6. Independent review findings, all declined here because they are Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384's design on main, byte-identical in this diff. Codex, Gemini, Cursor Composer and the Harper-domain adjudicator found nothing in the two conflict resolutions; the adjudicator's own ruling was "fix on main first and re-pick", since a v5.2-only change would fork the line. Carried to the dispatch findings for main: (a) one-shot selectors (liveReload=false, getReplicationCert) now arm the self-rearming failure-retry timer too, leaving an orphan rebuild chain per boot/restart while a record keeps failing (log noise and periodic scans, no serving impact); (b) deleting the last leaf record trips the zero-certificate guard, so its hostnames and default stay served while scheduleRebuild loops, which contradicts the new DESIGN.md sentence (the default half of that was already true before Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384); (c) a leaf record with an unparseable PEM is pushed to failedThisPass by both loops; (d) the tie regression test's waitFor predicate is already true at baseline; (e) new sinon/rewire uses in keys.test.js against AGENTS.md. None of these is changed on this branch.

Changes

Verification

Node v26.2.0 locally, npm ci against v5.2's lockfile, dist/ rebuilt. All runs under a private HOME (v5.2's mocha.init has no per-PID system-database isolation).

  • Fails on base: unmodified origin/v5.2 (78e528445) built in a side tree with the new cert-key-reload.test.ts copied in, HARPER_TEST_REQUIRE_FILE_WATCHERS=1 npm run test:integration -- integrationTests/security/cert-key-reload.test.ts: 0 pass / 1 fail, a worker stopped serving the last-good cert while the renewed cert had no matching key (served serial 8353068887037099000, the self-signed default, instead of 3001), with Error applying TLS ... key values mismatch logged by both HTTP workers. That is the A cert renewal whose table write misses the rebuild window downgrades TLS to the self-signed cert and never retries #2382 fallback the task describes.
  • Same command on this branch: 1 pass / 0 fail / 0 skipped (the retain-last-good assertion ran, not skipped, so file-watch delivery works on this box with the env gate on).
  • npx mocha unitTests/security/keys.test.js: 70 passing, including the new retain-last-good, whole-pass failure and latch rollback suites.
  • npm run test:unit:main, npm run test:unit:resources: 4579 passing / 2 failing and 1817 passing / 0 failing. The two test:unit:main failures are this box, not the diff: gitCredentials sees the dispatch daemon's own GIT_CONFIG_GLOBAL/GIT_EDITOR in the spawn env, and tokenAuthentication rsa_keys passes when re-run alone (load flake). Neither file imports security/keys.ts.
  • npm run build, npm run lint:required, prettier --check on the changed files: clean.
  • test:integration:all is left to this PR's CI (the local box cannot run the Ollama suite); the changed code is exercised end-to-end by cert-key-reload above.

Refs #2384
Refs #2382

Complexity: medium

Signed: Claude Fable 5.1

🤖 Generated with Claude Code

https://claude.ai/code/session_01LSCvLgqVLi4PMaUr6ZgeP4

Origin — the dispatch brief this PR was written from

Backport harper PR #2384 ('Publish TLS state transactionally so a failed rebuild never downgrades below last-good', squash-merged to main as 1e4c163, closes #2382) onto the v5.2 release branch. Open a PR from branch cherry-pick/v5.2/pr-2384 against base v5.2, titled 'cherry-pick: Publish TLS state transactionally so a failed rebuild never downgrades below last-good (#2384 → v5.2)', following the shape of the earlier manual backport PR #2827. On 5.2, a cert renewal whose key reaches a worker before the matching cert currently makes that worker fall back to the self-signed cert until the 5-minute re-read (#1394) heals it; with #2384 the worker keeps serving the last-good pair instead.

Acceptance

  1. Branch cherry-pick/v5.2/pr-2384 based on origin/v5.2 contains the full Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384 change, conflicts resolved, with no conflict markers left. 2) unitTests/security/keys.test.js passes on the branch (build first; mocha loads dist). 3) integrationTests/security/cert-key-reload.test.ts passes locally on Linux with HARPER_TEST_REQUIRE_FILE_WATCHERS=1, so it runs instead of skipping. 4) Prove the test can fail: run cert-key-reload against unmodified origin/v5.2 sources after a forced rebuild, and confirm it fails or falls back to the self-signed cert there. Report the result in the PR body. 5) The PR body lists every conflict resolution in security/keys.ts and any extra main commit you ported, with the reason. 6) Open as a draft PR against v5.2 with milestone v5.2. Do not touch main, and do not merge.

Dispatch: task harper-2384-backport-v52 · queued by kris-session · ran by claude/fable/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ 2fb147d

Human-Review-Need: 4 (decisions: retain-over-revoke-on-replace, retain-past-expiry, backoff-before-ready, error-level-summary, ci-fail-not-skip, backport-with-known-gaps) @ 2fb147d

…s below last-good (#2384)

* Publish TLS state transactionally so a failed rebuild never downgrades serving

A cert renewal whose hdb_certificate write misses the 1500ms rebuild debounce
made updateTLS pair the old table cert with the new on-disk key. The pass had
already cleared the shared SNI map, and a per-record build failure was
log-and-skip, so the record's hostnames fell through to the self-signed default
— production nodes served the wrong certificate for days, silently, with
nothing retrying (harper#2382).

updateTLS now builds the whole replacement state into pass-local candidates and
reconciles the live maps only after the pass completes. A record that is still
in the table but fails to build keeps every live entry it owns and its default
candidacy, so a transient mismatch can never publish a state worse than the one
being served; deleting the record remains the way to drop its contexts. A
failed pass arms a self-retry on the shared debounce with per-signature backoff
(1.5s doubling to 5min) and signature-throttled logging, and a pass-level throw
after .ready has settled leaves live state untouched instead of rejecting into
the void.

loadAndWatch's mtime latch now means "last successfully applied": it rolls back
on a synchronous throw or a rejected callback promise (equality-guarded so a
stale rejection cannot unlatch a newer reload), and the cert watcher returns
its certificateTable.put so a lost write is retried by the periodic poll
instead of being deduplicated forever.

The cert-key-reload integration test previously waited for a handshake failure
or fallback cert to establish its ordering — the exact behavior this change
removes — and would now skip silently. It confirms the renewed cert's arrival
through the system table instead, asserts every handshake keeps serving the
last-good pair while the matching key is absent, then delivers the key and
requires convergence on every worker.

Closes #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address cross-model review: fail closed on CA-set change, report all pass failures

Review findings on the transactional-publication commit (codex + harper-domain
adjudication):

- A retained context froze its ca: trust list and appended chain at its own
  build time, so retention across a CA-set change would keep honoring trust the
  operator just revoked. Retention now fails closed for a record whose built-in
  CA material no longer matches the candidate CA set; the retry pursues a fresh
  build instead.
- An unparseable authority row bypassed the failure path entirely: it re-logged
  on every pass outside the signature throttle, never armed the backoff, and
  let clearFailureState claim a clean recovery. It now reports through
  failedThisPass like every other record failure.
- The zero-cert early return swallowed per-record errors and retried on a flat
  debounce; it now reports the failure set and uses the backoff when failures
  drove the empty pass.
- The repeat-failure summary stays at error level so a stuck rotation keeps an
  alertable signal while the retained cert ages toward expiry, and a signature
  change or recovery now clears an armed retry timer instead of letting it run
  on a stale delay.
- The end-to-end test replaces its fixed settle sleep with a deadline-retried
  handshake hammer and gains HARPER_TEST_REQUIRE_FILE_WATCHERS to turn the
  inotify-limitation skip into a failure in pipelines that own this regression.
- integrationTests/**/node_modules/ is now ignored one level deeper, dropping a
  test-run-generated fixture dependency tree (with machine-local symlinks) that
  the previous commit swept in.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rebuild retained TLS contexts against the current CA set instead of dropping them

The prior fail-closed rule (drop retention when the CA set changed) went inert
in exactly the case it existed for: with every record failing, the empty pass
took the zero-cert early return and the whole old state — revoked trust
included — kept serving. It also punished CA additions with a needless drop.

A retained context's cert and key are still a consistent pair; only its frozen
ca: trust list can be stale. Retention now rebuilds the context from its own
options against the current CA material (memoized per pass), so revoked
client-CA trust is never carried forward, additions flow through, and the
zero-cert return can no longer bypass the rule — a successful rebuild keeps the
candidate set non-empty. A rebuild failure drops the record's entries as
before.

Also wires HARPER_TEST_REQUIRE_FILE_WATCHERS into the Linux integration job so
the cert-key-reload skip cannot silently retire the #2382 end-to-end assertion
in the pipeline that owns it, and corrects DESIGN.md's retention contract.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope retention rebuilds to mTLS listeners and route their failures through the throttle

Round-3 review deltas: the CA-aware rebuild only matters when the context
carries a ca: trust list, so non-mTLS listeners retain their existing object
untouched instead of paying a cold-path rebuild whose only failure mode was
converting a safe retention into a drop. A rebuild failure now reports through
the same failure-signature throttle as record failures instead of logging raw
on every pass, and the retention comment plus DESIGN.md state the real contract
for the empty-pass corner: when nothing else is servable, the zero-certificate
guard retains the old state — availability outranks the drop — while the
failure keeps retrying.

New tests pin the security property itself: an mTLS selector whose CA row is
deleted while its record is failing must publish a rebuilt context whose
options.ca no longer contains the removed PEM, and a non-mTLS record must keep
its identical context across a CA-set change.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Refresh retained contexts' CA bookkeeping and de-flake the throttle assertion

The non-mTLS retention short-circuit kept the retained object's
certificateAuthorities frozen; that array is mirrored into socket metadata read
by fronting proxies, so refresh it on retention. The corrupt-row throttle test
absorbs straggler first-logs over a longer settle window and then requires a
tight bound, so the assertion is sensitive to re-logging without being racy
against late selectors.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Take fresh ticket keys on retention rebuilds; compress over-narrated comments

A rebuilt retained context now calls getTicketKeys() like every fresh build
instead of inheriting the previous build's session-ticket keys (gemini review
finding on #2384). Comment pass per review nits: production comments compress
to the invariant they protect, with the full retention contract living in
DESIGN.md.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Compress the inherited zero-cert comments, scope trust claims, anchor the throttle test

The two comment blocks flagged across three review rounds (both inherited from
main) compress to their invariants: the zero-cert guard from 24 lines to 8, the
defaultContextSetThisPass note from 6 to 3. The trust-aware-retention wording in
keys.ts and DESIGN.md is scoped to what the code guarantees — NEW handshakes
never see revoked trust; established sessions and outstanding tickets are
unaffected, exactly as on a fresh build, since ticket keys are process-wide and
never rotate on trust changes (session-invalidation-scope is recorded as an
open decision on #2384).

The corrupt-row throttle test drops its tuned sleeps for a publish-anchored
assertion: by this selector's second completed pass every other selector's
single first-log is counted, and across two further completed passes any
per-record log growth fails — deterministic, and sensitive to exactly the
re-logging regression it guards.

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drive both rebuild loops from one snapshot; ties go to the incumbent; publish the wildcard flag

Review findings from kriszyp on #2384:

- The CA map and the serving contexts came from two separate search() calls,
  each owning its own read snapshot, so a write committing between them could
  publish a renewed leaf against the previous CA set. Both loops now iterate
  one materialized records array.
- Strict > comparisons let an equal-quality sibling take a failed record's
  hostname or the default on a tie. Retention now reclaims a hostname the
  retained context already owned on >=, and the live default wins the default
  tie only when it IS the retained record — a context retained merely for a
  hostname still needs strict > to become the default. An equal-quality
  regression test pins both rules.
- hasWildcards was only ever set true; with transactional publication the
  replacement flag is published directly, and retention marks it when it
  carries a wildcard-keyed entry forward (their keys start with '.').

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Attribute TLS failure logs to their listener so the throttle is testable

The per-record failure line now names the listener type — operationally useful
on multi-listener nodes, and it makes the throttle contract attributable: the
corrupt-row test drives a selector with a unique listener type and asserts
exactly one log for a stable signature, immune to the suite's accumulated
selectors logging the same record under their own types (the publish-anchored
form assumed foreign first-logs land before this selector's second pass, which
CI runner load disproved).

Refs #2382

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1e4c163)

Dispatch-Task: harper-2384-backport-v52
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSCvLgqVLi4PMaUr6ZgeP4
@kriszyp kriszyp added this to the v5.2 milestone Oct 2, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces transactional publication for TLS configuration updates to resolve issue #2382. By building the replacement state off-to-side and only updating the live maps upon successful completion, the system ensures that transient configuration mismatches or build failures do not downgrade the active serving state below the last-good configuration. Additionally, it implements self-retry with backoff, throttled logging for failed passes, and mtime latch rollback on failed file applies. The feedback suggests minor improvements to the new unit tests, specifically guarding the afterEach cleanup hooks to prevent secondary TypeErrors if the beforeEach hooks fail before initializing key variables.

Comment thread unitTests/security/keys.test.js
Comment thread unitTests/security/keys.test.js
@kriszyp
kriszyp marked this pull request as ready for review October 2, 2026 23:29
@kriszyp
kriszyp merged commit db20ab3 into v5.2 Oct 2, 2026
52 of 55 checks passed
@kriszyp
kriszyp deleted the cherry-pick/v5.2/pr-2384 branch October 2, 2026 23:29
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