fix(connections): rearm premint when a provider grant is invalidated - #8697
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of The claim that Design-Verdict: PASS Right seam (verified sole chokepoint), eligibility delegated to the existing scan, fire-and-forget with settlement — proportionate fix for a real reconnect-latency harm. [DESIGN-REVIEWED] 05c111b |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Final verification summary: the chokepoint claim holds (one production caller of First-Principles-Verdict: CONCERNS The fix earns its place, but the caller-side residue gate ships undeclared, and the teardown settlement leaves its one sibling — What this change shipsIntent: after a grant invalidation, the next Authorize is warm instead of a ~23s cold mint — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 05c111b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe discovery pass recorded no candidates. I independently examined both changed production files ( Verified: No findings. [OPUS-REVIEWED] 05c111b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
85960b5 to
5dc8463
Compare
5dc8463 to
f30dcd9
Compare
Correct, and the mechanism is sharper than "absent-ish": The completeness signal is residue: whether any artifact this transaction OWNS is still on disk when it ends, derived per branch from what that branch actually did rather than from one sweep at the end:
That re-read is inside the lock, unlike the handler's own post-lock survivor read. Both readings exist for different consumers and the difference is load-bearing: the handler's answer feeds a wire field and explicitly documents that reading it outside the lock "changes nothing", while this one decides whether to start an authorization, so it must not be able to disagree with the unlink it is judging. The handler's read and the The never-connected case stays warmable: no owned URLs means no residue, so a Disconnect on a provider with no stored grant re-arms exactly as before. Pinned red-first; all three fail on
Verified on |
This is a NEW finding at a reused span id — same file and reviewer lane as the Accepted as reported. The settlement shape is the runtime's own, extended to the registry: ORDER is load-bearing and is why the settlement sits in
The retire is in a Pinned red-first: Verified on |
The warm pool premints approval URLs ahead of need and deliberately skips any provider that already holds a grant. Nothing ever told it when a grant went away, so a provider stayed skipped after its credential was revoked, destroyed or expired -- and the next Authorize paid a full cold mint, measured at ~23s against a warm sub-second. (G2 pod bug-bash finding 3.) remove_provider_entry is the only production caller of revoke_local_grant, so it is the single place every local invalidation passes through. It now asks the pool to re-arm that provider, scheduled and never awaited: an activation spawns a helper process and negotiates OAuth, which must neither hold the config lock the transaction just released nor delay the user's answer. Eligibility is not re-derived. The ordinary tri-state candidate scan stays the authority, so a disabled entry, a provider with no usable auth configuration, a grant still present because the revoke was refused, and a grant cache that could not be read at all are skipped here exactly as arming already skips them -- a re-arm that would itself fail cold is noise. A provider already minting or waiting keeps its URL rather than having a redeemable one displaced, and at most one re-arm per provider is in flight, so repeated invalidations stack nothing. Also folds in the advisory dispositioned as follow-up on #8325: a spawn that never produced a process left its provisional generation directory behind for good, because the abandon path and startup scavenging both read a pid-0 marker as unproved and unproved must keep. A runtime that never reached a PID has no child that could still be reading that tree, which is the evidence the no-runtime branch already removes on, so that tree is released. A marker naming a PID still needs the identity proof: a spawn that failed after its child started may leave it running.
f30dcd9 to
05c111b
Compare
Upheld and taken. The harness reproduced the exact defect this PR's production change closes: One correction to the severity, stated because it changes what the fix is worth rather than whether to make it. The leak is LATENT on this head, not firing: every current consumer sets its release event and awaits its own task to completion before teardown, so by the time the fixture ran, the registry's task was already SHAPE — an @contextlib.asynccontextmanager
async def _rearm_registry():
warm._invalidation_rearms.clear()
try:
yield warm._invalidation_rearms
finally:
await warm._settle_invalidation_rearms()
Five consumers moved from a fixture parameter to GREP VERIFIED, as asked: Verified on |
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — approved.
Verified the security-critical property independently against the source at 05c111bab, not from the description: the re-arm never reads or reuses a grant. It only re-enters the existing premint path, which mints a fresh approval URL; revoke_local_grant and surviving_grant_artifacts stat and never open artifacts, so the module's presence-only credential boundary is preserved.
The residue gate is load-bearing, not defensive padding. grant_presence returns a definitive False as soon as either paired artifact is definitively absent (if False in verdicts: return False), and _warm_activation_candidates initiates consent on exactly that definitive False — so a half-unlinked pair was the strongest possible signal to warm, not merely an ambiguous one. Gating the schedule on residue is the correct fix at the correct layer, and it fails safe in the right direction: surviving_grant_artifacts counts an UNREADABLE artifact as surviving, so a stalled mount or permission error suppresses the re-arm rather than warming over live credentials. Residue is set on every branch that keeps or fails to fully remove a grant (census gap with owned pairs, shared-key continue, and the post-unlink in-lock re-stat), and the re-stat correctly sits inside the lock because it decides whether to start an authorization.
Eligibility genuinely is delegated, not re-derived: _rearm_invalidated_provider takes the candidate list from _audited_mintable_providers and hands it to warm_mint_all, where _warm_activation_candidates applies the is False (never falsy) presence test — so an unreadable grant cache skips rather than warms, and a still-present grant skips.
On the folded-in advisory: _spawn_left_no_process cannot rmtree a live process's tree. All three _release_runtime_generation call sites are gated on _kill_quietly returning True (a kill that times out returns False and the tree stays attached), and self._pid = self._process.pid is assigned immediately after subprocess creation with no intervening await — so a child that exists always yields pid > 0 and the pid <= 0 + runtime_pid <= 0 conjunction is only reachable when no process was ever created.
No AGENTS.md security invariant is touched: no security.py matchers, no hooks PreToolUse gate, no governance scopes, no denied-command rules, no computer-use surface. The SEL audit emits the premint read id and warns-then-proceeds on an unrecordable event, matching grant_observed's documented best-effort (not fail-closed) policy for presence-only stats.
Scope is proportionate: 197 production lines across two files, 517 test lines, 17 new tests covering precisely the branches that could go wrong (partial revoke, shared-grant keep, unreadable census, live-row non-displacement, dedup under repeated invalidation, off-loop no-op, teardown settlement, bound-generation-untouched).
One residual I am accepting rather than blocking on: a Connect landing between the _mints_lock live-row check and _claim_shared_mints could still have its waiting URL displaced. That is latency/UX only, carries no credential consequence, and is strictly more careful than the existing sibling _rearm_dead_warm_mint, which has no live-row check at all. The First Principles CONCERNS items are advisory and correct as characterized — the description under-declares the residue gate (the disposition comment corrects it openly), and the unsettled _premint_tasks sibling predates this PR.
Problem / Motivation
G2 pod bug-bash finding 3: when a provider's stored grant is invalidated -- a user
Disconnect, or the pod smoke-test grants destroyed after a run -- the warm premint pool never
re-arms for that provider. The user's next Authorize is cold, measured at ~23s, against a
sub-second warm one.
The cause is a gap in who tells whom. The pool premints approval URLs ahead of need and
deliberately skips any provider that already holds a grant, which is correct -- warming a
connected provider would initiate consent nobody asked for. But nothing ever told the pool
when a grant went away, so a provider that held one at the last scan stayed skipped
indefinitely, and the cold path silently became the normal path for exactly the providers a
user had just disconnected and was most likely to reconnect.
Why it matters
It hits the reconnect flow, which is the one flow where the user has already told us what they
want next. A 23-second wait on a button that is usually instant reads as a broken page rather
than a slow one, and it is worst in the pod bug-bash and smoke-test loops where grants are
destroyed by design -- so the tooling we use to validate Connections is the tooling most
reliably served the slow path.
What changed (motivation → approach → change)
The seam.
remove_provider_entryis the only production caller ofrevoke_local_grant,which is itself the only thing that unlinks a stored grant. That makes it the single
chokepoint every local invalidation passes through, so the pool learns about invalidation in
one place instead of one place per caller.
Scheduled, never awaited. An activation spawns a helper process and negotiates OAuth. That
must neither hold the config lock the invalidation transaction just released nor delay the
answer to a user who asked for a connection to be removed, so the re-arm is fire-and-forget
and the invalidation path never waits on a mint.
Eligibility is not re-derived. The ordinary tri-state candidate scan stays the authority,
so the re-arm inherits every veto arming already has: a disabled entry, a provider with no
usable auth configuration, a configured entry asking for something other than the registry, a
grant still present because the revoke was refused or shared, and a grant cache that could
not be read at all -- an absence nobody confirmed must never initiate consent. A re-arm that
would itself fail cold is noise, so a provider the scan does not return is simply not warmed.
This also means a Disconnect that deliberately kept the grant re-arms nothing, without the
seam having to reason about that case.
Guards against a storm. At most one re-arm per provider is in flight, so repeated
invalidations stack no duplicate premints. A provider already
mintingorwaitingis leftalone -- re-arming would displace a URL the card is showing and the user may be part-way
through redeeming. The scan is SEL-audited on the same read id as premint, because it is an
acted-on observation of the credential store.
Folded-in advisory (see Related Issues). A spawn that never produced a process left its
provisional generation directory behind for good: the abandon path and startup scavenging both
read a
runtime_pid: 0marker as unproved, and unproved must keep. So repeated spawn failuresaccumulated directories that nothing would ever reclaim. A runtime that never reached a PID has
no child that could still be reading that tree -- the same evidence the existing no-runtime
branch already removes on -- so that tree is now released. A marker that names a PID still
needs the full identity proof, because a spawn that failed after its child started may have
left it running.
Tests
12 new tests, all verified red on the parent commit (9 failed, 3 errored) and green here.
Re-arm behavior (
test/test_connections_warm.py):task's own state, not wall-clock timing
mintingandwaiting, parametrized) keeps its URL and its tokenwarm_mint_all: the card ends upwaitingon the new generationThe seam (
test/test_connections_disconnect.py):Folded advisory (
test/test_connections_warm.py):Also hardened the disconnect suite's
_wireharness to record the scheduled re-arm rather thanrun it -- the real scheduler starts an activation that spawns kiro-cli, so leaving it live would
have made all 53 tests in that file launch a helper process.
Suite counts, all green on the rebased head
85960b5f6:test_connections_warm.pytest_connections_disconnect.pytest_connections_premint.pytest_connections_handoff.pyGates: mypy
--platform linux(1293 source files, no issues), black, isort, flake8 7.1.0 allclean on the four touched files. No docs touched, so docs-lint does not apply.
Manual verification
N/A -- unit coverage sufficient. The two properties that actually needed proving are structural
rather than observational: the invalidation path does not await the mint (pinned on the task's
own unstarted state through the real scheduler), and the candidate scan remains the sole
eligibility authority (pinned by driving a vetoed provider through it). A live pod run would
re-measure the ~23s finding, which is what motivated the change rather than what validates it.
Related Issues
Folds in the Opus advisory dispositioned as owner-accepted follow-up on
PR #8325 (span=
0e5c8da823a6) -- thatdisposition froze an otherwise-green head and named the warm-rearm slice as the follow-up, so
this PR is it.
Pattern harvest
Rule candidate: review-prompt
Pattern: a pool that skips work based on cached external state has no way to learn that state
changed -- check every invalidation path for a corresponding re-arm, and check that the re-arm
re-uses the arming path's own eligibility scan rather than re-deriving it.
Why no screenshot: backend-only. The change is a scheduling seam between the grant-ownership
transaction and the warm-mint pool; no panel, component, layout or copy is added or altered. The
user-visible effect is latency on an existing button, which a still frame cannot show.
Checklist