Skip to content

feat(gatekeeper-kit): replayable runs with account-adjudicated expiry - #441

Open
ndisidore wants to merge 7 commits into
mainfrom
nathan/gatekeeper-kit-replayable-refresh
Open

feat(gatekeeper-kit): replayable runs with account-adjudicated expiry#441
ndisidore wants to merge 7 commits into
mainfrom
nathan/gatekeeper-kit-replayable-refresh

Conversation

@ndisidore

@ndisidore ndisidore commented Sep 3, 2026

Copy link
Copy Markdown
Member

CredentialSource.run gains a replayable option: a credential rejection is retried once with credentials minted through a new refreshCredentials channel, and only a rejection of those freshest credentials reports expiry.
Derived-bearer ports (google, notion, confluence, marketo) stop retiring a healthy grant over a routine stale bearer.
The refresh result is only observed, never adopted: plain reads stay the snapshot's single writer, which dissolves the interleaving races a second writer would need fences for.

CredentialSource.run gains a replayable option: a credential rejection is
retried once with credentials minted through a refreshCredentials channel,
and only a rejection of those reports expiry — so derived-bearer ports stop
reporting routine stale bearers as grant death. The flag without a channel
throws at the call.

The refresh is observed, never adopted: plain reads stay the snapshot's
only writer, which removes the second concurrent writer earlier revisions
fenced around. noteCredentialsExpired now returns the account's verdict on
the reported identity — an explicit false resolves as the fixed retry
message with the cache authority dropped; anything else (lost answers
included) fails closed as accepted. The verdict is asked first, then clear
and fence land as one synchronous transition, and a reconnect crossing the
refresh fences any authority not adopted past it. Replays coalesce per
rejected read via SingleFlight's new object keys.

Plan §4.6/§4.13/§5.6/§5.8/§6 reconciled with the reduced model.
@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Sep 3, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview: pr441-nathan-gateke-b32dece1

https://pr441-nathan-gateke-b32dece1-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

noteCredentialsExpired now returns ExpiryVerdict ("accepted" | "superseded")
instead of a bare boolean whose polarity every call site had to decode. Same
fail-closed semantics: only an explicit "superseded" resolves as retryable;
anything else — lost answers included — reads as accepted.
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding. Gatekeeper-kit tests pass: 343 tests.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding.

github run

…tryable

A reconnect adopted after a read now outranks everything downstream of it:
the dead-mint shortcut, any refresh-channel failure (not just confirmed
expiry), and the replay's second rejection all resolve as the retryable
changed error without spending an ask the account could only answer
"superseded" — closing the paths where a delayed dead-mint response or a
lost note answer could clear a live authority or false-expire a freshly
reconnected account.
devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown
  • P1 packages/gatekeeper-kit/src/credentials.ts:505: #moved() loses reconnect evidence when another adjudication calls #supersede() and clears #generation. A delayed pre-reconnect refresh can then replay under the previous principal. Track movement separately from clearable cache authority and add this interleaving test.

github run

…tay refused

Clearing the authority on an observed crossing destroyed the very evidence
that proved other in-flight same-generation reads superseded: a delayed
old-generation mint could then be replayed under the pre-reconnect
principal, and a channelless rejection adjudicated against an identity the
account had already replaced. Crossings now persist in a #crossed set —
recorded from a refresh result (before the dead shortcut, since a dead mint
carries the same evidence), by the post-refresh supersession refusal, and
at a plain read's adoption over a different generation — and #moved
consults it, so the proof outlives any later authority clear on both the
replay and report paths.
@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown
  • P1 packages/gatekeeper-kit/src/credentials.ts:519: #moved() detects an adopted generation crossing without recording it. If authority later clears, another stale run can replay under the pre-reconnect principal. Persist the crossing inside #moved() and add this sibling-run interleaving test.

github run

…thority at the ask

Two review findings against the crossing evidence, both reproduced:

The crossing comparison rode the clearable authority, so an adoption from
an unknown authority (a superseded verdict, then a reconnect) recorded
nothing — two clears straddling a reconnect let a delayed same-generation
mint replay under the pre-reconnect principal. A #seen field now retains
the last unfenced adoption's generation across clears; unfenced adoptions
serialize, so comparing against it records only true crossings, and #moved
never consults it (a fenced read can be newer than last-seen).

#adjudicate waited for the account's verdict before dropping the rejected
authority, so cache-first partitionedBy readers kept serving the rejected
partition for the length of a slow or stuck report RPC. The authority now
drops at the ask and again at the verdict; the death mark still waits for
an accepted answer.
…ce expiry asks

Extract #replay, hoist the single superseded-read guard, resolve the
refresh channel once at entry, name RefreshCredentials<Creds>, and share
one noteCredentialsExpired round trip per identity across concurrent
reporters (#note now answers a plain ExpiryVerdict).
@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown
  • P2 packages/gatekeeper-kit/src/credentials.ts:530: Expiry verdicts cannot safely coalesce by identity. If the first report accepts id-a then stalls notifying, a reconnect can install id-b before another id-a reporter joins. The second caller inherits "accepted" although a fresh RPC would return "superseded", producing a false reconnect error. Keep adjudication per report or fence the flight by account state, and add this interleaving test.

github run

…the plan

The fencing argument only defeats the withAuthRetry composition; record
why the replay stays source-side against the account-minting-inside-the-
report shape (caller-visible retries, an extra round trip, authority
drops on a healthy account, death determinism leaving the replay frame).
// the grants the account keeps serving after their reports. The fence holds even against a
// read resolving a reconnect: generations are opaque and equality-only, so a fenced response
// cannot prove itself newest — authority stays the last unfenced fetch.
if (fence === this.#clearFence && !this.#dead.has(current.identity)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Keep the reported identity quarantined while adjudication is pending

#adjudicate() clears the authority before awaiting the account, but the reported identity is not added to #dead until an accepted answer. A get() landing during that wait can therefore return the same identity and this branch re-adopts its generation; a subsequent KvTtlCache read can hit the rejected partition for as long as the verdict stalls. This contradicts the stated round-trip bypass guarantee and regresses the previous implementation, which marked the identity dead before awaiting notification. Track pending identities separately and refuse to adopt them until adjudication resolves; please cover a cache hit after the mid-adjudication get() already exercised by the test above.

}
// A parallel replay already had this same-generation mint's grant adjudicated dead — don't
// replay it.
if (this.#dead.has(second.identity)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Preserve a live same-generation successor before re-adjudicating a dead mint

This shortcut only has generation-based movement protection. Two runs can mint id-b/gen-a, one can adjudicate id-b dead, then another refresh can commit and a plain read can adopt live id-c/gen-a before the other delayed id-b/gen-a response arrives. #moved(gen-a) is false, so this call clears id-c authority; if the report RPC fails, #note() also converts that stale report into an accepted expiry and gives the user a false reconnect error. Since an adopted non-dead identity succeeded the already-dead mint on the same generation, resolve this as changed without adjudicating, and add that same-generation interleaving test.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

Posted 2 actionable inline findings.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant