Skip to content

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

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

feat(gatekeeper-kit): replayable runs with account-adjudicated expiry#441
ndisidore wants to merge 33 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: when the provider rejects credentials, the account decides whether they are expired, superseded, or unavailable, and a replayable operation may retry once with a same-connection successor without crossing a reconnect.

This is an improved version of fetchWithAuthRetry that many gatekeepers hand roll

Derived-bearer ports can heal a rejected current credential inside reportCredentialsRejected, with identity-keyed single-flight coalescing concurrent mints; grant-death ports instead notify the Workshop and return expired.

Each attempt receives its own { identity, generation } fence, while named CredentialsChangedError and CredentialsExpiredError results survive RPC boundaries and stale reads, reports, and refetches cannot re-establish cache authority.

@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

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

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as outdated.

@ask-bonk

This comment was marked as outdated.

@ask-bonk

This comment was marked as outdated.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

Posted 2 actionable inline findings.

github run

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

No new findings. Previously published inline findings remain applicable to the current head.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as outdated.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as outdated.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

No new findings. Previously published findings remain applicable to 831417a. Tests were not rerun because pnpm is unavailable.

github run

@ndisidore

Copy link
Copy Markdown
Member Author

P2 packages/gatekeeper-kit/src/credentials.ts:341: A reconnect during notify() leaves snapshot() rethrowing the old grant’s expiry despite new credentials being current. Recheck identity after notification.

Not worth actioning. The window is the notify RPC only; fresh()'s #overtaken already covers the wide mint-await window. The delta is error flavor for one caller — it sees expired instead of the reconnect that landed mid-notify — and its re-entry refetches the live credentials. No false notify is created (the grant was confirmed dead when notify fired; the latch handles dedup).

P2 packages/gatekeeper-kit/src/credentials.ts:635: A reconnect adopted while the rejection report is pending is unconditionally cleared; a failed report then falsely expires the live connection. Reconcile the verdict with any adopted successor before clearing authority.

The scenario needs a triple coincidence: 401 + a concurrent fetch adopting a successor inside the ask window + the report reply lost (fail-closed "expired"). Net damage: the one reporting caller gets CredentialsExpiredError instead of CredentialsChangedError. Consider this accepted

@ask-bonk

This comment was marked as outdated.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding. Tests were not run because pnpm is unavailable.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding.

github run

@github-actions github-actions Bot added the delivery Changes to CI or release delivery label Sep 4, 2026
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding. Current GitHub build, test, and lint checks pass; local tests were unavailable because pnpm is not installed.

github run

@ask-bonk

This comment was marked as outdated.

…ordinator logs by vendor

Also: the cache example marks its read replayable, run()'s throws doc names
the fetch-path expiry source, CredentialsWithIdentity is declared as the read
plus credentials, and an integration test pins the two-attempt cap when every
mint succeeds but the provider keeps rejecting.
…unt re-serves the rejected identity

The superseded retry's refetch adopts before the same-identity check throws,
restoring the vouch for a credential the provider just rejected — so a
cache-first re-entry serves the partition instead of re-entering, masking the
lazy account for a TTL while the non-replayable path honestly bypasses.
Supersede before the throw, as the ask does.
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-replayable-refresh branch from 8d3db43 to 2c1c7a4 Compare September 4, 2026 18:45
@github-actions github-actions Bot removed the delivery Changes to CI or release delivery label Sep 4, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as outdated.

…ecided expiry

Three paths decided expiry before awaiting the Workshop notification and
never looked again — snapshot's rethrow and both of adjudicateRejection's
expired verdicts — so a reconnect landing during the notify RPC still
surfaced grant death despite the live replacement. Re-check the fence after
the await, as every other await in the coordinator already does: snapshot
serves the replacement triple, the verdicts answer superseded.

Also refuse a retry whose refetch the source did not itself adopt: a
fenced-out response is stale evidence that can postdate an adopted
reconnect, and be0768a's collapse deleted the crossed-generation
bookkeeping that used to refuse it — the account only adjudicates failures
that reach it, and this execution never asks. The live-state comparison
replaces the deleted set: generations never revert, so a stale refetch can
never re-match adopted state.
…into one method

A #notified(identity, notify) coordinator method now owns "await the
notification, then report whether the identity fence survived" — snapshot's
catch and #expired both consume it, snapshot no longer pays the fence check on
non-notify failures, and the module-level notified() helper is absorbed.
snapshot also reuses #connected() instead of restating its body. Tests share a
stallingNotify() scaffold and the two mid-notify verdict cases fold into one
it.each; the fenced-out-refetch test uses the source() helper.
@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

No new findings. Previously published inline findings remain applicable to the current head.

github run

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

No new findings. Previously published findings remain applicable.

github run

capnweb rebuilds errors on decode, keeping enumerable own props but
mapping unknown names to plain Error — so the name-only matchers went
blind across the Gadget-facing hop. Both errors now carry a code the
matchers accept alongside the name, following workshop-shared's
coded-error precedent.
…dicts

The adopted-successor shortcut was only exercised replayable, so an
unconditional replay would double-execute non-replayable operations
unnoticed; and repeat reports all re-answered expired, indistinguishable
from a cached verdict wrongly expiring a reconnect.
@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

LGTM!

github run

…xtures

A MarkedError base owns the name+code transport-stable convention for both
credential errors, and the CredentialsChangedError throw sites name the error
in place. The credentials suite folds its hand-rolled accounts into source(),
queuedSource(), and mutableSource() helpers and hoists stalledRun for the
composition describe.
@ndisidore
ndisidore force-pushed the nathan/gatekeeper-kit-replayable-refresh branch from c288f8b to 6754a17 Compare September 4, 2026 23:39
@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

LGTM!

github run

@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.

Devin Review found 1 new potential issue.

⚠️ 1 issue in files not directly in the diff

⚠️ Empty vendor IDs lose log attribution

An empty vendor ID makes defineActions discard the configured attribution. Both credential helpers and observer tracking repeat this check, leaving their logs unattributed.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

LGTM!

github run

…isconnect

"Superseded" promises a live successor, but a clear() landing during a
rejection adjudication — mid-notify, mid-mint, or before the report — rotated
the fence with nothing stored, so the verdict sent callers re-entering into a
disconnected account. A moved fence now answers by successor: superseded when
one is stored, expired when a disconnect left none, matching snapshot()'s own
handling of the same race. The disconnect itself still never notifies.
}
this.#logger.error("malformed credential rejection verdict", {
event: "credentials.rejection.verdict.malformed",
error: new Error(`unexpected verdict: ${String(verdict)}`),

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 Do not log the malformed verdict value

This fail-closed branch copies an arbitrary out-of-contract RPC result into the logged error message. If a hand-written account implementation accidentally returns a provider response or bearer string instead of a verdict, that credential is persisted in server logs, contrary to the logging rule forbidding tokens and response bodies. Log only static/type metadata, and add a test that a malformed secret-like value is absent from the logged event.

@ask-bonk

ask-bonk Bot commented Sep 5, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding. Tests were not run because pnpm is unavailable.

github run

…"" reads

A thrown or malformed rejection report still fails closed as expiry, but is
answered internally as "unadjudicated" and never dead-marks: a transient
account outage must not retire a possibly-live identity — for a non-refreshing
grant that disabled cache adoption for the rest of the activation. The source
now refuses a read served under the reserved "" identity instead of letting a
hand-written stub wedge every rejection as retryable, and the verdict union
derives from one runtime tuple. Docs and the composition suite stop implying
snapshot's stored grant crosses RPC unprojected: a new test wires the
coordinator's Grant against the source's public projection.
// Dual guard, neither subsumes the other: the fence blocks fetches started before an expiry
// "" is reserved for a never-connected read: adopting live credentials under it would wedge
// every rejection as retryable, since "" always adjudicates superseded.
if (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.

P1 Clear the previous cache authority before rejecting this read

After a successful id-a/gen-a fetch, this source may hold cache entries partitioned by gen-a. If the account reconnects as B and a hand-written stub accidentally serves B under the reserved empty identity, this branch throws but leaves #generation === "gen-a"; a later cache-first request can therefore return A’s data to B without another credential fetch. Since this response cannot establish which principal is current, clear/fence the authority before throwing, and extend this test to start from an established partition.

@ask-bonk

ask-bonk Bot commented Sep 5, 2026

Copy link
Copy Markdown

Posted 1 actionable inline finding.

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