feat(gatekeeper-kit): replayable runs with account-adjudicated expiry - #441
feat(gatekeeper-kit): replayable runs with account-adjudicated expiry#441ndisidore wants to merge 7 commits into
Conversation
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.
Preview:
|
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.
|
Posted 1 actionable inline finding. Gatekeeper-kit tests pass: 343 tests. |
|
Posted 1 actionable inline finding. |
…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.
|
…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.
|
…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).
|
…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)) { |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
|
Posted 2 actionable inline findings. |
CredentialSource.rungains areplayableoption: a credential rejection is retried once with credentials minted through a newrefreshCredentialschannel, 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.