Skip to content

spec: design user disable/enable and email change (#356, #357) - #358

Merged
mforce merged 13 commits into
mainfrom
spec/user-disable-and-email
Aug 2, 2026
Merged

mforce merged 13 commits into
mainfrom
spec/user-disable-and-email

Conversation

@mforce

@mforce mforce commented Aug 2, 2026 •

Copy link
Copy Markdown
Owner

Design doc only — no code. Implementation lands as #356 (disable, first) then #357 (email, reuses its machinery).

Second commit reworks the design after an adversarial review found the enforcement mechanism does not do what the first draft claimed. The spec ends with a What the first draft got wrong section recording all nine corrections, so the same reasoning is not re-derived later.

What it covers

Two gaps on the Users page: no way to take someone's access away, and no way to correct the address they log in with. Today a departing worker can only have their password reset — the account stays live — and a typo'd email means abandoning the account for a second one, leaving a phantom user on the history the first one recorded.

The load-bearing correction

The first draft rotated SecurityStamp and revoked refresh tokens, then called the session terminated. It is not. The access token carries sub, email, account_id, role, must_change_password and nothing else; validation checks signature, issuer, audience, lifetime. Nothing on the request path reads a stamp. So:

  • an email change would leave the target's live tab working, against the spec's own promise;
  • a per-request read of DisabledAt meant re-enabling resurrected every unexpired pre-disable token — a suspension lifted after five minutes handing back the session it was meant to end.

The user now carries a monotonic CredentialEpoch, stamped into every access token and compared per request. Disable, email change, and every password reset bump it; enable deliberately does not. That asymmetry is the point. It also closes a race bulk revocation cannot: RefreshAsync loads token+user then revokes and inserts a child, while RevokeAllActiveForUserAsync is a separate bulk update — so a refresh in flight can insert a live child after the revoke commits. Minted under the old epoch, that child is useless.

Other corrections worth reading

  • The last-Owner guard was called race-safe on ConcurrencyStamp. Two Owners disabling each other touch different rows and share no concurrency token — both guards pass, both commit, zero Owners remain. It needs the account row locked FOR UPDATE with the count re-read inside the lock (the pattern AccountRepository already has), and that becomes a standing constraint on every future Owner-removing path including Users: change a user's role after creation (promote/demote) #355.
  • The email write omitted NormalizedUserName — the column carrying the unique index. NormalizedEmail's index is not unique. Omitting it reserves the old address forever and bypasses conflict detection on the new one.
  • The step-up justification described a takeover chain that does not need an email change. CreateUserHandler gates only Owner creation, so a stolen Owner token already mints a Manager, ungated. Gating these operations is still right, for honest reasons; the durable-persistence gap belongs to Require recent step-up authentication for privileged user administration #308 and is filed as Step-up (#308) gates only Owner creation — a stolen Owner token still buys durable access #360.
  • The retry section is deleted. IdempotencyMiddleware already runs the pipeline under SingleAttemptExecution, so the inner Identity save is never independently replayed — and the probe it prescribed keyed on "the email is already ours," which is not attempt-unique evidence the way a freshly minted 256-bit token hash is.
  • A sole Owner may not change their own email, and recover-admin gains an id-based lookup, since locating by email is circular after an email-typo lockout.
  • Middleware placement is stated as an order, not a vibe, with AccountId in every lookup — ApplicationUser has no tenant query filter.
  • The SPA must carry the 401's error title through auth teardown, or the promised "your account has been disabled" can never render.

Out of scope

Hard delete (#272). Role editing (#355). Step-up coverage (#360). Cross-replica step-up replay (#338) is documented as a residual, not fixed here.

Two gaps on the Users page: there is no way to take someone's access
away, and no way to correct the address they log in with. A departing
worker can only have their password reset, which leaves a live account
behind; a typo'd email is unfixable without abandoning the account.

Records the design for both, and the reasoning behind the choices that
are not obvious:

- Disable is soft, never a hard delete — users are referenced by audit
  rows and created-by trails. Erasure stays with #272.
- One flag covers offboarding and suspension. Suspension is what sets
  the enforcement bar: access tokens live ~15 min with no server-side
  denylist, so a per-request check is the only option under which the
  flag means what the UI claims. A short-TTL cache would reintroduce
  the multi-replica problem #338 already tracks.
- Self-disable and last-active-Owner are both refused. bootstrap-admin
  creates exactly one Owner, and recover-admin resets a password
  without re-enabling anything, so without both guards one click can
  leave a farm with no UI path back in. recover-admin now clears
  DisabledAt for the same reason.
- Both operations require a step-up grant (#308). A stolen Owner token
  could otherwise repoint a worker's email and then reset that worker's
  password — which is deliberately ungated for worker targets — turning
  a 15-minute window into durable access.
- The ConcurrencyStamp retry hazard is the #269 probe shape, not
  SingleAttemptExecution: neither write is a counter, so a replay is
  unobservable and the cure is asking the database whether our own
  write is durable.

Role editing (#355) is filed separately — same page and machinery, but
out of scope here.
@mforce mforce mentioned this pull request Aug 2, 2026
90 tasks done

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8ea51ac74

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +239 to +242
**Race** — two Owners disabling each other concurrently must not land at zero
active Owners. This is the `Version`-bump family of bug the repo has shipped
three times, wearing Identity's `ConcurrencyStamp` clothing. It gets a
parallel-race integration test like every other mutation.

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 Badge Serialize the last-owner decision

When two active Owners disable each other concurrently, both transactions can observe two active Owners and then update different AspNetUsers rows. ConcurrencyStamp only detects competing updates to the same user row, so neither update conflicts and both can commit, leaving zero active Owners. The parallel test proposed here will expose the race, but the design also needs account-scoped serialization—such as an advisory lock or another database-enforced invariant—around the count and disable operation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c25fcf2 (before this round landed). The spec now takes the account row FOR UPDATE via the existing AccountRepository.GetCurrentLockedAsync, and re-reads the target, its Owner membership, and the active-Owner count inside the lock before writing. Recorded as a standing constraint on every future Owner-removing path, including the #355 role-demotion slice.

Comment on lines +81 to +84
The email is the login identifier. Changing it rotates the security stamp,
revokes every refresh token, and invalidates outstanding step-up grants, the
same posture `SetUserPasswordAsync` already takes. The collateral is one
re-login; the principle is that a changed identifier re-establishes the session.

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 Badge Enforce the rotated stamp on bearer tokens

In the current JwtTokenService and authentication pipeline, access tokens neither contain SecurityStamp nor trigger a stamp lookup, while the proposed middleware checks only DisabledAt. Consequently, an email change leaves every existing bearer token usable until expiry, and a token issued before a disable becomes usable again if the account is re-enabled within that window. Rotating the stamp and revoking refresh tokens therefore does not provide the claimed session re-establishment; the design needs a per-request stamp/generation comparison or must explicitly weaken and test that guarantee.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c25fcf2 (before this round landed), and this finding is what drove the rewrite. Confirmed against source: the token carries sub/email/account_id/role/must_change_password only, and validation checks signature, issuer, audience, lifetime — no stamp anywhere on the request path. Enforcement is now a monotonic CredentialEpoch claim compared per request; enable deliberately does not restore the old value. Round 2 then found the same fix incomplete on the refresh side — see the IssuedEpoch thread.

An adversarial review of the first draft found the enforcement mechanism
does not do what the draft claimed, and the claim was load-bearing for
both features.

The draft rotated SecurityStamp and revoked refresh tokens, then called
the session terminated. It is not: the access token carries sub, email,
account_id, role, and must_change_password, and validation checks only
signature, issuer, audience, and lifetime. Nothing on the request path
reads a stamp. So an email change left the target's live tab working,
and a per-request read of DisabledAt meant re-enabling resurrected every
unexpired pre-disable token — a suspension lifted after five minutes
handed back the session it was meant to end.

The user now carries a monotonic CredentialEpoch, stamped into every
access token and compared per request. Disable, email change, and every
password reset bump it; enable deliberately does not. That asymmetry is
the point, and it also closes a race the draft missed: a refresh already
in flight can insert a live child token after a bulk revoke commits, and
the child is useless if it was minted under a superseded epoch.

Also corrected:

- The last-Owner guard was called race-safe on ConcurrencyStamp. Two
  Owners disabling each other touch different rows and share no
  concurrency token. It needs the account row locked FOR UPDATE, with
  the count re-read inside the lock — the pattern AccountRepository
  already has, and a constraint on every future Owner-removing path.
- The email write omitted NormalizedUserName, which is the column
  carrying the unique index; NormalizedEmail's is not unique. Omitting
  it would reserve the old address forever and bypass conflict
  detection on the new one.
- The step-up justification described a takeover chain that does not
  need an email change at all — CreateUserHandler gates only Owner
  creation, so a stolen Owner token already mints a Manager. Gating
  these operations is still right, for honest reasons; the persistence
  gap belongs to #308 and is filed separately.
- The retry section is deleted. IdempotencyMiddleware already runs the
  pipeline under SingleAttemptExecution, so the inner Identity save is
  never independently replayed, and the probe it prescribed keyed on
  evidence that is not attempt-unique.
- A sole Owner may not change their own email; recover-admin gains a
  lookup that does not require knowing the typo.
- Middleware placement is stated as an order, not a vibe, with AccountId
  in every lookup — ApplicationUser has no tenant query filter.
- The SPA must carry the 401's error title through auth teardown, or the
  promised "your account has been disabled" can never render.

Every correction is recorded at the end of the spec so the same
reasoning is not re-derived later.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex please review the revised spec (commit c25fcf2).

The first draft was reviewed adversarially and reworked. The central change: enforcement moved from a DisabledAt flag read per request to a monotonic CredentialEpoch claim, because the access token carries no security stamp and nothing on the request path reads one — so the draft's session-termination promise was false, and re-enabling would have resurrected every unexpired pre-disable token.

Also corrected: the last-Owner guard now takes the account row FOR UPDATE (two Owners disabling each other touch different rows, so ConcurrencyStamp gives no conflict point); the email write covers NormalizedUserName, which is the column with the unique index; the retry section is deleted because IdempotencyMiddleware already runs the pipeline under SingleAttemptExecution; a sole Owner may not change their own email.

Worth attacking specifically:

  1. Does the epoch actually terminate every path, or is there a credential minted before the bump that still validates after it?
  2. Is the account-row FOR UPDATE sufficient for the last-Owner invariant, or does it need SERIALIZABLE / a deferred constraint trigger?
  3. The epoch is an int — is anything about overflow, default 0, or the migration's backfill wrong?
  4. Middleware ordering: UseAuthentication → TenantResolution → CredentialEpoch → MustChangePassword → UseAuthorization → Idempotency. Any position where it fails open?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c25fcf21ab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +238 to +239
One migration: three columns, `CredentialEpoch` non-nullable with default `0`,
no data statements. `MigrationSecurityReviewTests` stays green.

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 Badge Bind each refresh token to its issuance epoch

In the barrier-controlled refresh-vs-disable/email race, the storage design adds CredentialEpoch only to ApplicationUser, so the durable refresh-token record has no issuance epoch to compare with the user's current value. If a refresh loads the old user state, the epoch bump and bulk revocation commit, and then that refresh inserts its child, the child's access token is rejected, but presenting the child again lets RefreshAsync read the new user epoch and mint a valid current-epoch pair—immediately after an email change or after re-enabling a disabled user. Persist or cryptographically bind the issuance epoch to every refresh token and reject a mismatch before rotation; otherwise the claimed race closure and session termination do not hold.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 87f922d.

Verified the mechanism before changing anything: RefreshToken has no epoch field, and RevokeAllActiveForUserAsync is a bulk ExecuteUpdate over RevokedAt == null — it cannot see a row that does not exist yet, so a child inserted after it commits stays Active. Presenting that child makes RefreshAsync read the user's current CredentialEpoch and mint a valid current-epoch pair: immediately after an email change, and after re-enable for a disable.

RefreshToken now carries IssuedEpoch, stamped at every mint site (LoginAsync, the rotation, ChangeOwnPasswordAsync's re-issue), and RefreshAsync refuses a mismatch before rotating — so a superseded token is never consumed and never mints a child. A bump kills the family by construction rather than by winning a race. Bulk revocation stays for promptness, but the guarantee no longer rests on it. Default 0 backfills correctly in both directions, so the deploy evicts no live session.

The race tests were also restated: assert by presenting the child token, and re-present it after enable. The weaker assertion — that the child's access token is rejected — passes with the defect fully present, which is how the previous draft talked itself into being finished.

Recorded in the spec as a general rule: a revocation epoch has to be bound to every credential the system will later accept, not just the one whose weakness prompted it. The previous rewrite was specifically about the access token having no binding, and left the credential whose entire job is to outlive it with none.

Codex round 2, against the fix from round 1 — and it is right.

The second draft put CredentialEpoch on ApplicationUser alone. That
rejects the access token a racing refresh mints, and stops there. The
child REFRESH token is a durable row with RevokedAt == null, which
RevokeAllActiveForUserAsync never saw (it is a bulk ExecuteUpdate over
rows unrevoked at that instant; it cannot see a row that does not exist
yet). Present that child and RefreshAsync reads the user's CURRENT
epoch and mints a perfectly valid current-epoch pair — immediately after
an email change, and after re-enabling for a disable. The resurrection
the epoch exists to prevent, re-entered through the refresh door.

RefreshToken now carries IssuedEpoch, stamped at every mint site
(LoginAsync, the rotation, ChangeOwnPasswordAsync's re-issue), and
RefreshAsync refuses a mismatch BEFORE rotating, so a superseded token
is never consumed and never mints a child. A bump kills the family by
construction rather than by winning a race. Bulk revocation stays for
promptness but is no longer what the guarantee rests on.

Default 0 backfills correctly in both directions: existing users start
at epoch 0 and every existing token was minted under it, so the deploy
evicts no live session.

The race tests are restated to assert by PRESENTING the child token,
and to re-present it after enable. Asserting only that the child's
access token is rejected passes with the defect fully present — which
is exactly how the second draft talked itself into being finished.

The general lesson is recorded in the spec: a revocation epoch must be
bound to every credential the system will later accept, not just the
one whose weakness prompted it. The rewrite was specifically about the
access token having no binding, and left the credential whose entire
job is to outlive it with none.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex all three findings are addressed in 87f922d (the IssuedEpoch P1) and c25fcf2 (the last-owner serialization P1 and the bearer-token stamp P2 — both landed before your round-2 review read them). Replies are on each thread.

The round-2 finding was the real one: binding CredentialEpoch to the user alone rejects the access token a racing refresh mints, but the child refresh token is a durable row RevokeAllActiveForUserAsync never saw, and presenting it mints a valid current-epoch pair. RefreshToken.IssuedEpoch is now stamped at every mint site and checked before rotation.

Please re-review 87f922d, and attack these specifically:

  1. Is IssuedEpoch now stamped at every site that mints a refresh token, or is there a path (grace-advance replay per Auth: idempotent refresh to close the tab-death cross-tab residual (#169 follow-up) #176, the seeders, a CLI verb) that mints one without it and therefore inherits epoch 0?
  2. Does the Auth: idempotent refresh to close the tab-death cross-tab residual (#169 follow-up) #176 grace-advance interact badly with the epoch check — can a graced token whose replacement was minted pre-bump still be advanced post-bump?
  3. Same question one layer out: is there any other credential the system later accepts that carries no epoch binding, so the same door reopens? Step-up grants are in-memory today (Move the step-up grant registry to a shared store before running >1 replica (follow-up to #308) #338), but that changes if they are made durable.
  4. Does the account-row FOR UPDATE genuinely serialize the last-Owner decision, or does it need SERIALIZABLE with 40001 handling / a deferred constraint trigger?
  5. CredentialEpoch as int with default 0 — anything wrong with the backfill, or with a user whose epoch is bumped by an automated path often enough to matter?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87f922dfad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +267 to +270
One migration: three columns on `AspNetUsers` and one on `refresh_tokens`, both
epochs non-nullable with default `0`, no data statements. Default `0` is correct
for the backfill in both directions: every existing user starts at epoch `0`, and
every existing refresh token was minted under it, so no live session is evicted

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 Badge Invalidate legacy refresh tokens at the epoch cutover

If any password reset raced RefreshAsync before this migration, the refresh can already have inserted an attacker-held child after the reset's bulk revocation. Backfilling both that token and its user to epoch 0 certifies the stale child as current, so it remains usable after deployment until another epoch bump. Because the migration cannot infer which legacy tokens predate the user's latest credential change, the cutover must establish a clean boundary by revoking existing refresh tokens or assigning them an epoch that differs from the users' initial epoch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in bd864c7.

You are right that the framing was backwards: the previous draft called "no live session is evicted" a virtue, and it is the defect. The pre-epoch code already carries the revoke-vs-refresh race, so a legacy row sitting RevokedAt IS NULL may already be an attacker's orphan from an earlier password reset — and backfilling every user and token to 0 certifies exactly that row as current, where it stays usable until some unrelated future bump.

Agreed too that the migration cannot be selective: it has no way to tell which legacy tokens predate their user's last credential change. So the cutover now revokes every existing refresh token (UPDATE refresh_tokens SET "RevokedAt" = now() WHERE "RevokedAt" IS NULL) and accepts one forced re-login at deploy. For a farm's worth of users that costs nothing, and it is the only moment the mechanism can be handed a trustworthy starting state.

Added a cutover test asserted against a database seeded pre-migration, since the guarantee is a boundary drawn across data the migration did not create. Also noted that this is the migration's only data statement — no INSERTs, so MigrationSecurityReviewTests' shape assertions are unaffected, but its count pins need updating with it.

Comment on lines +372 to +375
`RefreshAsync` refuses a disabled user, **and refuses any token whose
`IssuedEpoch` differs from the user's current `CredentialEpoch`** — checked
before the rotation, so a superseded token is never consumed and never mints a
child. That check, not the bulk revocation, is what makes a bump terminate the

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 Badge Compare the epoch before replay revokes the family

The check must occur before revoked-token grace/replay handling, not merely before rotation. In the current IdentityProvider.RefreshAsync flow (src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs:102-120), a revoked pre-bump token whose replacement is no longer active reaches RevokeAllActiveForUserAsync before the user is loaded; after the legitimate user logs in again, an attacker can therefore present that superseded token and revoke the new current-epoch refresh token. Reject an epoch mismatch before either grace advancement or replay-triggered family revocation, while preserving family revocation for genuine same-epoch reuse.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed against source and fixed in bd864c7. This one was precise and I had the placement wrong.

Verified the ordering in RefreshAsync: the token is resolved, and if RevokedAt is not null the #176 grace/replay branch runs — TryGraceReplacementAsync, and on failure RevokeAllActiveForUserAsync then return — before FindByIdAsync ever loads the user. So an epoch comparison placed anywhere near the rotation simply never executes on that path.

And the consequence is the part I would have missed: it is not a bypass, it is a denial of service. An attacker holding a superseded pre-bump token waits for the legitimate user to log in again, presents the dead token, and burns down their brand-new current-epoch family. Repeatably. No "can the attacker get in?" test would ever have caught it.

The comparison now sits ahead of the grace branch: resolve the token, load the user, compare epochs, and only then enter grace/replay. A mismatch fails closed and inert — generic Identity.InvalidRefreshToken, no family revocation — because a superseded token is evidence about a session that is already dead and says nothing about the live one. Family revocation is preserved for genuine same-epoch reuse, which is the theft signal #176 exists to catch, and there is now a test pinning both sides so the fix cannot be quietly implemented by weakening that detection.

Recorded the shared shape, since this is its third appearance in the document: a check is only as good as the earliest path that can reach what it protects. Draft two bound the epoch to the access token and missed the refresh token; draft three bound it to the refresh token and missed the branch that acts on one without loading the user.

mforce added 3 commits August 1, 2026 19:20
Codex round 3, against round 2's fix. Both findings are real, and both
are the same mistake in different clothes.

The cutover was designed to preserve live sessions, and the previous
commit message called that a virtue. It is the defect. The pre-epoch
code already has the revoke-vs-refresh race, so a legacy token sitting
RevokedAt == null may already be an attacker's orphan from some earlier
password reset. Backfilling every user and token to 0 certifies that row
as current-epoch, and it stays usable until an unrelated future bump.
The migration cannot tell which legacy tokens predate their user's last
credential change, so it cannot be selective: the cutover now revokes
every existing refresh token and accepts one forced re-login. That is
the only moment the mechanism can be handed a trustworthy starting
state, and for a farm's worth of users it costs nothing.

The epoch comparison was placed "before the rotation," which is too
late. RefreshAsync resolves the token, and if it is already revoked runs
#176's grace/replay branch — calling RevokeAllActiveForUserAsync and
returning — BEFORE FindByIdAsync ever loads the user. So the check never
runs on that path. Not a bypass: a denial of service. An attacker
holding a superseded pre-bump token waits for the legitimate user to log
in again, presents the dead token, and burns down their brand-new
current-epoch family, repeatably. The comparison moves ahead of the
grace branch, and an epoch mismatch now fails inert — generic error, no
family revocation — because a superseded token is evidence about a
session that is already dead. Same-epoch reuse still burns the family,
which is #176's actual purpose, and there is now a test pinning both
sides so the fix cannot be implemented by weakening theft detection.

The shared shape is named in the spec, since this is its third
appearance: a check is only as good as the earliest path that can reach
what it protects. Draft two bound the epoch to the access token and
missed the refresh token. Draft three bound it to the refresh token and
missed the branch that acts on one without loading the user. Enumerate
every entry point to the guarded state, then place the guard above all
of them — not next to the code that prompted it.

The new test is the one that would not have been written otherwise: it
fails as a denial of service, so no "can the attacker get in?" test
would have caught it.
Two summary lines still said the epoch check sits before the rotation,
which is precisely the placement the previous commit found insufficient.
Both now point at the grace/replay branch.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex both round-3 findings are addressed in bd864c7 (plus c4c143f/b198dfb removing stale phrasing the fix contradicted). Replies are on each thread.

  • Cutover (P1): agreed the framing was backwards — preserving live sessions was the defect, not a feature, since a legacy RevokedAt IS NULL row may already be an orphan of the pre-epoch revoke-vs-refresh race. The migration now revokes every existing refresh token and accepts one forced re-login. Test asserts against a pre-migration seeded database.
  • Check placement (P2): confirmed against IdentityProvider.RefreshAsync — the grace/replay branch and RevokeAllActiveForUserAsync run before FindByIdAsync, so the comparison never executed on that path. It now sits ahead of the grace branch and a mismatch fails inert (no family revocation). Both sides pinned by tests so it cannot be implemented by weakening Auth: idempotent refresh to close the tab-death cross-tab residual (#169 follow-up) #176's theft detection.

Please re-review b198dfb. Specific attacks worth trying:

  1. With the comparison moved ahead of grace/replay, is there now a path where a legitimate same-epoch grace advance is wrongly rejected — i.e. did the fix trade the DoS for a broken dead-tab retry?
  2. RevokedByGrace bounds the grace to a single hop. Does the epoch check interact with that bound in either direction — can a graced token now be advanced twice, or can a legitimate first hop be refused?
  3. The cutover revokes tokens but leaves user epochs at 0. Is there any residual credential — an access token issued seconds before the migration, a step-up grant — that survives the boundary?
  4. Is RefreshAsync still the only place a refresh token is accepted, or does another path (reuse detection, the Purge expired refresh_tokens (retention sweep; sibling of #259) #270 purge sweep, a CLI verb) read one without an epoch comparison?
  5. Anything wrong with revoking in the migration itself rather than in application code — ordering against Database:MigrateOnStartup=false and the out-of-band migrate job (Deploy: separate the migration/owner DB role from the runtime app role (no DDL at request-time) #263)?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b198dfb14d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +270 to +274
One migration: three columns on `AspNetUsers` and one on `refresh_tokens`, both
epochs non-nullable with default `0`.

**The cutover revokes every existing refresh token.** Backfilling users and
tokens both to `0` would preserve live sessions — and that is the defect, not a

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 Badge Assign legacy refresh tokens a retired epoch

Fresh evidence in the revised cutover is that revocation changes only RevokedAt while legacy tokens and users both remain at epoch 0. After the user logs in again, their new token is also epoch 0; presenting any stolen legacy token therefore passes the early epoch comparison and, because the migration revoked its replacement or left none, enters the replay branch and revokes the user's new family repeatedly. Moreover, Production runs the migration before the new serving process, so an old binary still serving during a rolling cutover can insert another token whose new column receives the database default 0, and the new binary will accept it. Initialize users/new credentials to an epoch distinct from all legacy/defaulted rows, or establish another boundary after old writers have stopped.

AGENTS.md reference: AGENTS.md:L50-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 21df253. Both halves were right, and the second half is one I would not have found.

The differentiation. Revoking legacy tokens is not the same as retiring them. With users and legacy tokens both at 0, the victim's new token after re-login is also 0 — so the stolen legacy token passes the comparison, falls into the revoked-token replay branch, and burns the fresh family. The denial of service from the previous thread, reintroduced by the fix for the one before it.

Users now start at 1 (column default and C# initializer), legacy rows stay at 0, and since epochs only increment, 0 is permanently retired — no user will ever carry it.

The rolling deploy. This is the part I had not considered, and your AGENTS.md:50 pointer is the right one: migrate runs as a separate job before the new serving process (#263), so an old binary can still be inserting refresh tokens while the columns already exist. Its INSERTs take the database default — which is now exactly the retired sentinel. Those rows are inert by construction rather than by timing, so there is no window in which an old writer mints something the new binary honours. Their holders get logged out; nobody gets a credential the mechanism cannot see.

On keeping the revocation anyway. The epoch separation already makes legacy tokens inert, so the UPDATE is now defence-in-depth — but the direction matters and is worth stating: if the comparison ever regresses, revoked-and-retired tokens degrade to a denial of service, whereas active-and-retired ones would degrade to access. Given the choice of failure modes, take the one that locks people out.

Tests now assert the second half explicitly — after the user logs in again, the stolen legacy token still cannot burn their new family. Revocation alone passes the first assertion and fails that one. Plus a default-0 row standing in for the old binary mid-rollout.

Recorded as its own rule, since it is a different mistake from the previous three: a boundary needs a value, not just an event. "Revoke everything at the cutover" is an event, and an event does not survive a writer that is still running or a row written after it.

… event

Codex round 4, against round 3's cutover fix. Right again.

Revoking every legacy refresh token is not differentiation. Legacy
tokens and users both sat at epoch 0, so once the victim logs in again
their NEW token is also 0 — a stolen legacy token passes the epoch
comparison, enters the revoked-token replay branch, and burns down the
fresh family. The exact denial of service the check-placement fix
existed to prevent, reintroduced by the fix for the cutover.

Users now start at 1 and epoch 0 is permanently retired: it is the value
a row gets when written by something that does not know the column
exists, and it can never equal a live user's epoch.

That also closes a rolling-deploy window the previous draft never
considered. Production runs migrate as a separate job BEFORE the new
serving process (#263), so an old binary can still be inserting refresh
tokens while the columns exist. Its INSERTs take the database default,
and 0 is precisely the value no user will ever carry — those rows are
inert by construction rather than by timing. Nobody gets a credential
the new binary honours; their holders just get logged out.

The migration still revokes legacy tokens even though the epoch
separation already makes them inert, and the direction of that
defence-in-depth matters: if the comparison ever regresses,
revoked-and-retired tokens degrade to a denial of service, while
active-and-retired ones would degrade to access. Given the choice, take
the failure mode that locks people out.

Named the rule, because it is a different mistake from items 10-12: a
boundary needs a VALUE, not just an EVENT. 'Revoke everything at the
cutover' is an event, and an event does not survive a writer still
running or a row written afterwards. A reserved sentinel the new world
can never produce holds without depending on when anything happened.

Tests: the cutover case now asserts the second half — that after the
user logs in again, the stolen legacy token still cannot burn the new
family. Revocation alone passes the first assertion and fails that one.
Plus a default-0 row standing in for the old binary mid-rollout.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex round-4 finding addressed in 21df253. Reply is on the thread.

Both halves were right. Users now start at 1, legacy rows stay at 0, and since epochs only increment, 0 is permanently retired — the value a row receives when written by something that does not know the column exists, and never equal to a live user's epoch. That is what separates the victim's post-re-login token from the stolen legacy one, and it is also what makes the #263 rolling window safe by construction rather than by timing: an old binary's INSERTs take the default and are inert the moment the new binary takes over.

Kept the legacy revocation as defence-in-depth, deliberately in that direction — a regressed comparison then degrades to lockout rather than to access.

Please re-review 21df253. Worth attacking:

  1. Is 0 genuinely unreachable for a user? bootstrap-admin, the demo and simulation seeders, and CreateUserAsync all mint users — does any path bypass the initializer and land on the column default?
  2. The reverse for tokens: is there a mint site that would leave IssuedEpoch at 0 for a current user, locking out someone legitimate?
  3. ChangeOwnPasswordAsync returns a fresh pair while bumping the epoch. Is the ordering right, or does it hand back a token stamped with the pre-bump value and log the caller out of the session it just re-issued?
  4. Does the migration's UPDATE interact badly with the Purge expired refresh_tokens (retention sweep; sibling of #259) #270 purge sweep or the Auth: idempotent refresh to close the tab-death cross-tab residual (#169 follow-up) #176 grace window if either runs concurrently with the migrate job?
  5. Same standing question as last round: any credential the system later accepts that still carries no epoch binding?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21df253b48

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +512 to +513
- A refresh token active before the migration is rejected after it, and the user
is forced to log in again.

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 Badge Reject legacy access tokens at cutover

The cutover coverage exercises only the legacy refresh token, but every access token issued by the current JwtTokenService also predates the epoch claim and remains cryptographically valid for up to 15 minutes. If CredentialEpochMiddleware treats an absent or unparsable claim as “not applicable” rather than a mismatch, those bearer tokens continue authorizing requests after deployment, contradicting the forced-re-login boundary. Add a pre-migration access-token case that makes an authenticated request after cutover and requires 401, explicitly pinning missing/malformed epoch claims to fail closed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 87dc267. You are right, and the omission had a cause worth naming.

The cutover covered the credential I had just been thinking about (refresh tokens) and forgot the other one already in flight. Every access token minted before the deploy carries no epoch claim and stays cryptographically valid for its remaining ~15 minutes, so a middleware reading absence as "not applicable" lets the entire pre-cutover fleet authorize straight through the boundary the migration just drew.

Absent or unparsable now parses to 0 — the sentinel retired in the previous round — so it fails through the ordinary comparison with no separate branch to forget. That keeps it uniform rather than a special case, which matters because a special case is exactly what gets dropped in a refactor.

On why this was easy to miss: the pull comes from the codebase's own convention. must_change_password is omitted from the token when false, so "claim absent ⇒ doesn't apply" is an idiom already in the reader's hand — and it is correct there. Stated in the spec as a rule: a convention that is right for a feature flag is a vulnerability for a revocation check. An omitted optional flag means "no"; an omitted mandatory one has to mean "refuse". Nothing in the shape of the code distinguishes them.

Tests: a pre-migration access token making an ordinary authenticated request post-cutover must 401, and a present-but-malformed claim is pinned separately — absent and unparsable are two code paths and only one of them is obvious.

Also recorded the pattern across rounds, since this is the third instance: items 11, 13 and 14 are all migration-boundary failures, in three different credential types. A design that introduces a per-request check has to enumerate every credential already in flight on the day it ships, not just the ones it mints afterwards.

Codex round 5. The cutover covered refresh tokens and forgot the other
credential already in flight: every access token minted before the
deploy carries no epoch claim and stays cryptographically valid for its
remaining ~15 minutes. A middleware that reads an absent claim as 'not
applicable' lets the entire pre-cutover fleet authorize straight through
the boundary the migration just drew.

Absent or unparsable now parses to 0 — the sentinel retired last round —
so it fails through the ordinary comparison with no special branch to
forget.

Recorded why this was easy to miss, because the pull is from the
codebase's own conventions: must_change_password is OMITTED from the
token when false, so 'claim absent => does not apply' is an idiom
already in the reader's hand, and it is correct there. A convention that
is right for a feature flag is a vulnerability for a revocation check.
An omitted optional flag means no; an omitted mandatory one has to mean
refuse. Nothing in the shape of the code distinguishes them.

Tests: a PRE-migration access token making an ordinary authenticated
request after cutover must 401, and a present-but-malformed claim is
pinned separately — absent and unparsable are two code paths and only
one of them is obvious.

Also named the pattern across rounds: items 11, 13 and 14 are all
migration-boundary failures in three different credential types. A
design that introduces a per-request check has to enumerate every
credential already in flight on the day it ships, not just the ones it
mints afterwards.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex round-5 finding addressed in 87dc267. Reply is on the thread.

Absent or unparsable epoch claims now parse to 0 — the retired sentinel — so they fail through the ordinary comparison rather than a separate branch. Tests pin a pre-migration access token getting 401 post-cutover, and a malformed claim separately.

That is three migration-boundary defects in a row (refresh-token epoch equality, the rolling-deploy default, now pre-cutover access tokens), so I have gone looking for the fourth rather than waiting to be told. Please check specifically:

  1. Step-up grants across the cutover. They are in-memory (Move the step-up grant registry to a shared store before running >1 replica (follow-up to #308) #338) and bind to SecurityStamp, so a process restart clears them — but is there a deploy shape (rolling, old process still serving) where a grant issued pre-cutover is honoured post-cutover?
  2. The SPA's stored tokens. tokenStore holds an access token in memory per tab; after the deploy the first request 401s and client.ts transparently refreshes — against a refresh token the migration revoked. Does that produce a clean logout, or a retry loop / a confusing error?
  3. Anything cached off a token's claims rather than re-read per request — claims.ts decodes role client-side, and TenantResolutionMiddleware reads account_id. Does any of it outlive the epoch check?
  4. Original standing question, still open: any credential the system later accepts that carries no epoch binding at all?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87dc267834

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +298 to +300
**Two: it survives a rolling deploy.** Production runs `migrate` as a separate
job *before* the new serving process (#263), so an old binary can still be
serving while the columns exist. It knows nothing about `IssuedEpoch`, so its

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 Badge Drain old readers before enabling epoch-gated changes

In the rolling sequence described here, epoch 0 protects only requests that reach the new binary. A replica still running the old authentication pipeline validates bearer signature/lifetime without CredentialEpochMiddleware, so after a new replica disables or changes credentials for a user, a load balancer can still route that user's pre-deploy access token—and any associated step-up operation—to the old replica, which continues authorizing it until drained. This violates the immediate suspension and forced-relogin boundary; require old serving processes to be drained before exposing the new mutations, or introduce a compatibility rollout phase rather than handling only rows written by old processes.

AGENTS.md reference: AGENTS.md:L50-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 35c701a. This is the inverse of the round-4 finding and I had only solved one half of it: that one was about rows written by an old binary, this is about requests served by one.

The retired 0 makes an old writer's tokens inert. It does nothing about an old replica, which runs the pre-epoch pipeline and has no CredentialEpochMiddleware to consult — so a disable on a new replica leaves the load balancer free to route that user's pre-deploy access token to an old one that authorizes it. Immediate on part of the fleet, which is worse than a documented delay because nothing surfaces it.

Agreed that nothing the mutation does can close this; the gap belongs to the replica that never learned to enforce. The feature now ships in two deploys with the ordering as a hard constraint:

The drain itself belongs to the deployment/ops repo per this repo's host-agnostic boundary; what belongs here is the requirement, and it is now stated as one — an orchestrator that reports "rollout complete" because the new replicas are healthy, without confirming the old ones are gone, does not satisfy it.

Delivery is therefore three PRs. The split is not bookkeeping: (1) is where every migration-boundary correction from rounds 3–5 lives, and it is the part that has to be correct on a mixed fleet.

Recorded the general form: adding a per-request check is a fleet-wide change, not a code change. Until the last old process is gone, the guarantee is whatever the weakest replica enforces — so the gate and the promise cannot ship together. And noted the asymmetry with the round-4 fix, which is why the sentinel was not enough here: a sentinel defends against bad data; only ordering defends against absent enforcement.

mforce added 2 commits August 1, 2026 19:43
…change

Codex round 6, and it is the inverse of round 4's finding: that one was
about rows WRITTEN by an old binary, this is about requests SERVED by
one.

The retired epoch 0 makes an old writer's tokens inert. It does nothing
about an old replica, which runs the pre-epoch pipeline and has no
CredentialEpochMiddleware to consult. So during a rolling deploy an
Owner disables a user on a new replica while the load balancer keeps
routing that user's pre-deploy access token to an old one, which
authorizes it. The suspension is immediate on part of the fleet — worse
than a documented delay, because nothing surfaces it.

Nothing the mutation does can fix that; the gap belongs to the replica
that never learned to enforce. So the feature now ships in two deploys
with the ordering as a hard constraint:

  A. the mechanism, inert — epoch columns, migration and cutover, the
     claim, the middleware, RefreshAsync's comparison, and the SPA 401
     plumbing. Makes no new promise: the only epoch bumps are the
     password-reset paths that already revoke refresh tokens today, so
     an old replica behaves exactly as it does now and there is no
     window of false assurance.
  B. the mutations — disable, enable, email change, the SPA. Not exposed
     until every pre-A process is drained.

The drain belongs to the deployment repo; what belongs here is the
requirement, stated as one. An orchestrator that calls a rollout
complete because the new replicas are healthy, without confirming the
old ones are gone, does not satisfy it.

Delivery is therefore three PRs, not two, and the split is not
bookkeeping: (1) is where every migration-boundary correction lives and
is the part that must be correct on a mixed fleet; #356 and #357 become
ordinary feature work on a mechanism already everywhere.

Named the general form: adding a per-request check is a fleet-wide
change, not a code change. Until the last old process is gone the
guarantee is whatever the weakest replica enforces, so the gate and the
promise cannot ship together. And noted the asymmetry with the round-4
fix — a sentinel defends against bad data; only ordering defends
against absent enforcement.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex round-6 finding addressed in 35c701a + 1a93707. Reply is on the thread.

You were right that the sentinel only covers old writers, not old readers. The mechanism now ships as its own inert deploy (#364) — epoch, migration/cutover, claim, middleware, RefreshAsync comparison, SPA 401 plumbing — with the mutations (#356, #357) gated behind it and behind a full drain of the pre-#364 fleet. Deploy A makes no new promise, since its only epoch bumps are the password-reset paths that already revoke refresh tokens today.

Six rounds, six real defects, and the last four were all variants of "the old world is not gone yet" in different credential types and now in different processes. So please attack that seam once more, from angles I have not already asked about:

  1. Is deploy A genuinely inert on a mixed fleet? Its cutover revokes every refresh token and forces a re-login. An old replica still serving will hand those users a 401 on refresh — does the SPA on an old replica handle that as a clean logout, or does deploy A itself create a user-visible break it claims not to?
  2. Rollback. If deploy A is rolled back after the migration ran, users sit at epoch 1 and tokens carry IssuedEpoch the old binary ignores. Anything unsafe on the way back down, or on a re-roll-forward?
  3. bootstrap-admin / recover-admin / the seeders run as separate processes against the same database. Post-A, do they all produce users at a valid epoch, and pre-A-drained, can any of them mint a credential the new binary rejects or the old one over-honours?
  4. Still open from earlier rounds and never answered: any credential the system later accepts that carries no epoch binding at all?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a937075ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +309 to +310
**The migration still revokes every existing refresh token** (`UPDATE
refresh_tokens SET "RevokedAt" = now() WHERE "RevokedAt" IS NULL`), even though

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 Badge Drain old refresh readers before revoking legacy tokens

During Deploy A's mixed-fleet window, this update marks every legacy token revoked while the current pre-A IdentityProvider.RefreshAsync can still serve requests. That implementation treats a revoked token without a live replacement as replay and calls RevokeAllActiveForUserAsync, so an old tab refreshing after the user has re-logged in on a new replica revokes the freshly issued epoch-1 family; the old SPA then redirects cleanly, but only after invalidating the user's new session. Postpone the revocation/enforcement cutover until old refresh readers are drained, or introduce a compatibility phase that prevents the old replay branch from acting on cutover-revoked rows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5a04260. You caught a straight contradiction: deploy A cannot both revoke every refresh token and claim to be inert.

Traced it through the pre-epoch RefreshAsync and it is exactly as you describe — a revoked token with no live replacement is read as a replay, answered with RevokeAllActiveForUserAsync. So a legacy row the migration marked revoked is, to an old replica, indistinguishable from a stolen one. The user re-logs in on a new replica and gets a clean epoch-1 family; a forgotten tab refreshes against an old replica with its cutover-revoked token; the old replay branch fires and burns down the family that was just issued. The old tab redirects to login tidily, having destroyed the user's live session on the way out.

The migration is now additive only — four columns with defaults, no UPDATE, no INSERT. An old replica sees a database byte-identical in every column it reads and behaves exactly as it does today; a new replica enforces fully. No state in between.

Nothing is lost by dropping it, which is the part I had wrong earlier: the revocation was never what drew the boundary. The epoch separation is — legacy tokens carry IssuedEpoch = 0, users carry 1, and the mismatch is rejected inert. Revoking them was belt-and-braces on top of a mechanism that already held. That belt-and-braces now lives in a revoke-legacy-tokens verb run after the drain, when no reader can misinterpret it, and the direction of the defence is preserved: a regressed comparison degrades to lockout rather than to access.

Added a test that asserts the migration mutates no rows — specifically so that anyone "helpfully" reintroducing the cutover revocation into it fails CI rather than discovering this on a mixed fleet.

The rule I took from this, recorded in the spec: a compatibility window is a constraint on writes, not only on reads. It is not enough that the old binary tolerate the new schema; it must tolerate every value the new deploy puts into it. Adding a column is safe. Changing a row an old code path already has opinions about is not, however harmless the change looks in the new code.

Comment on lines +287 to +288
Since epochs only ever increment from `1`, a token carrying `0` can never match
any user and is rejected inert, forever.

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 Badge Revoke default-zero tokens before rolling Deploy A back

The claim that epoch-0 tokens are inert forever stops holding if Deploy A is rolled back to the pre-A binary, because that binary ignores IssuedEpoch. In particular, an old replica can insert an active default-0 child after a new replica's password-reset revocation—the exact refresh race described earlier—and the new binary rejects it only while A remains deployed; a rollback makes that child usable again despite the password reset. Specify a rollback cutover that drains A processes and revokes all active refresh tokens before restarting the pre-A fleet, or otherwise make rollback fail closed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5a04260. "Epoch 0 is inert forever" was doing unexamined work — it quietly assumed a binary that reads IssuedEpoch is still running.

The concrete chain is the one you name: during the mixed-fleet window an old replica inserts an active default-0 child right after a new replica's password reset revoked the family. The new binary rejects that child on sight, so it looks handled. A rollback drops the check entirely and resurrects it — and the password reset that was supposed to kill it silently did not.

Rollback therefore is not "redeploy the previous image." The order is: drain every deploy-A process, revoke all active refresh tokens, then start the pre-A fleet. One forced re-login, and it makes the rollback fail closed. The procedure belongs to the deployment/ops repo per this repo's host-agnostic boundary; the requirement is now stated here, and there is a test that mints a default-0 child and asserts it is unusable under a simulated pre-A binary — i.e. only if the rollback revocation happened.

The general rule, recorded: a security property that holds only while the new code is deployed is not a property, it is a configuration. Every claim of the form "X can never happen" needs the rollback path checked before it is written down — rollback is the one deploy nobody rehearses and everybody eventually performs.

Worth noting these two findings are the same mistake pointing in opposite directions along the deploy timeline: the other thread was the old binary still running forward of the migration, this is the old binary running after it. Both were invisible as long as I reasoned about the new code in isolation.

Codex round 7, two P1s, both answering questions I had asked it to check
and both fatal to claims the previous draft made.

Deploy A was called inert while its migration revoked every refresh
token. Those are contradictory. The pre-epoch RefreshAsync reads a
revoked token with no live replacement as a REPLAY and answers it by
revoking the whole family — so a legacy row the migration marked revoked
is, to an old replica, indistinguishable from a stolen one. The user
re-logs in on a new replica, a forgotten tab refreshes against an old
one, and the old replay branch burns down the epoch-1 family that was
just issued. The deploy that promised to break nothing breaks the
session of anyone who left a tab open.

The migration is now additive only: four columns with defaults, no
UPDATE, no INSERT. An old replica sees a database byte-identical in
every column it reads. Nothing is lost, because the revocation was never
what drew the boundary — the epoch separation is, and legacy tokens are
already rejected inert on the mismatch. The defence-in-depth moves to a
revoke-legacy-tokens verb run after the drain, when no reader can
misinterpret it.

Second: 'epoch 0 is inert forever' quietly assumed the new binary is
still running. A rollback to the pre-A image drops the IssuedEpoch check
entirely, resurrecting any default-0 token — including a child an old
replica inserted right after a new replica's password reset revoked the
family. The reset that was supposed to kill it silently did not.
Rollback now has its own ordered procedure: drain deploy-A processes,
revoke all active refresh tokens, then start the pre-A fleet.

Two rules recorded. A compatibility window is a constraint on WRITES,
not only on reads: it is not enough that the old binary tolerate the new
schema, it must tolerate every value the new deploy puts into it.
Adding a column is safe; changing a row an old code path already has
opinions about is not, however harmless it looks in the new code. And: a
security property that holds only while the new code is deployed is not
a property, it is a configuration — every 'X can never happen' needs the
rollback path checked before it is written down, because rollback is the
one deploy nobody rehearses and everybody eventually performs.

Tests: assert the migration mutates no rows (fails if anyone
reintroduces the cutover revocation into it), and assert rollback fails
closed. Marked item 11's remedy as superseded — its diagnosis was right,
its fix was not.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex both round-7 findings addressed in 5a04260. Replies on each thread.

  • Deploy A's cutover revocation (P1): you were right that it contradicts the inertness claim — the pre-epoch replay branch reads a cutover-revoked token as theft and burns the user's fresh family. The migration is now additive only; the revocation moves to a post-drain revoke-legacy-tokens verb. The boundary was always drawn by the epoch separation, not the revocation. There is now a test asserting the migration mutates no rows, so reintroducing it fails CI.
  • Rollback (P1): "inert forever" assumed the new binary keeps running. Rollback now has an ordered procedure — drain A, revoke all active refresh tokens, then start pre-A — with a test that a default-0 child is unusable under a simulated pre-A binary.

Seven rounds, nine real defects, and the last five have all been variants of "the old world is still here." I would rather find the remaining ones than ship them, so please keep going — and specifically:

  1. The revoke-legacy-tokens verb is new surface. Same run-then-exit shape as migrate/recover-admin. Does it need environment gating, an account scope, an audit row? Can running it twice, or at the wrong moment, hurt?
  2. Deploy A is now additive — is it actually? Adding a non-nullable column with a default rewrites the table on some Postgres versions and takes a lock. Against Deploy: separate the migration/owner DB role from the runtime app role (no DDL at request-time) #263's out-of-band migrate job and DatabaseReadyHealthCheck, is there a lock-duration or readiness hazard on a large refresh_tokens (idempotency_records grows unbounded: full response body stored per successful write, no purge job #259/Purge expired refresh_tokens (retention sweep; sibling of #259) #270 note it grows)?
  3. The two-deploy split assumes B is strictly additive over A. Is there anything in Users: disable / re-enable a user #356/Users: change a user's email address #357 that an A-only replica would mishandle if B rolls out gradually — the mirror of the problem we just fixed, one deploy later?
  4. Still unanswered from round 2: any credential the system accepts that carries no epoch binding at all?

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a04260573

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +517 to +520
1. **Deploy A — the mechanism, additive.** `CredentialEpoch`, `IssuedEpoch`, an
**additive-only** migration, `JwtTokenService` stamping the claim, the
middleware, and `RefreshAsync`'s comparison. No disable, no enable, no email
change, and — critically — **no row mutations**: an old replica sees a

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 Badge Ship disabled-user readers in Deploy A

During a gradual Deploy B rollout, a disabled user's login or refresh can still land on an A-only replica. Deploy A's explicit contents include only epoch stamping/comparison, while the current IdentityProvider.LoginAsync and RefreshAsync have no disabled-state checks; if those checks remain with #356, the A replica can mint a current-epoch token that every B replica accepts, bypassing the suspension. Include all disabled-state readers—login, refresh, epoch middleware, and step-up issuance—in A, leaving only the mutations and UI for B.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in ea9820e — this answers the question I had asked, and the answer is no, B is not strictly additive over A. The disabled-state checks in LoginAsync, RefreshAsync and StepUpGrantService.IssueAsync were scheduled with the mutations, so during B's own gradual rollout an A-only replica would mint a current-epoch token for a disabled user that every B replica then honours. The split is now every reader in A, only mutations and UI in B, with DisabledAt shipping unused in A. Stated as the general rule: readers ship before writers, always — a reader deployed early is inert, a writer deployed early is a hole.

Comment on lines +524 to +528
2. **Drain.** Every pre-A process gone.
3. **Deploy B — the mutations.** `POST /disable`, `POST /enable`, `PUT /email`,
and the SPA. By the time an epoch bump can be triggered by a *new* operation,
every serving replica enforces the check. The optional
`revoke-legacy-tokens` verb can also run from here on.

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 Badge Delay the Deploy B SPA until every API replica has its routes

When Deploy B itself rolls gradually, a browser can load the new SPA from a B replica and then have /disable, /enable, or /email routed to an A replica where those routes do not exist, producing intermittent 404s for the new administrative actions. The stated drain only excludes pre-A processes, not the A replicas being replaced by B, so stage the B API across the fleet before exposing the SPA or require an A-to-B drain before clients receive the new UI.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ea9820e. Same class as the A→B boundary, one deploy later, and the stated drain did not cover it. B's API is now staged fleet-wide before the SPA is exposed (or A→B drained before clients receive the new UI), so a browser served the new bundle by a B replica cannot get a 404 from an A replica without the route.

Comment on lines +325 to +328
So the migration is **additive only**: four columns with defaults, no `UPDATE`,
no `INSERT`. An old replica sees a database byte-identical in every column it
reads, and behaves exactly as it does today. A new replica gets full enforcement
immediately. There is no state in between.

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 Badge Bound lock acquisition for the additive migration

On the repository's PostgreSQL 18 target, constant defaults avoid a heap rewrite, but each ALTER TABLE ... ADD COLUMN still needs an ACCESS EXCLUSIVE lock. If an old serving process has a long transaction touching refresh_tokens, the migrate job can wait indefinitely and its queued exclusive request can in turn block later refresh/login traffic; DatabaseReadyHealthCheck only gates the replacement process and does not protect the still-serving old fleet. Specify a short migration lock_timeout with a fail-and-retry procedure so this metadata-only migration cannot become an unbounded authentication outage.

AGENTS.md reference: AGENTS.md:L50-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ea9820e. "Additive" was doing more work than it earned — constant defaults skip the heap rewrite, but each ADD COLUMN still takes ACCESS EXCLUSIVE, and a queued exclusive request blocks login and refresh behind it. You are right that DatabaseReadyHealthCheck is no help: it gates the replacement process, not the old fleet still taking traffic. The migration now sets a short lock_timeout and fails fast. A failed migrate job is a deploy that did not start; an unbounded lock wait is a deploy that took auth down. refresh_tokens is also the table that grows — #259 and #270 exist because of it — so it is the one most likely to be busy.

Comment on lines +335 to +337
The belt-and-braces still has a home, just a later one. Once the pre-A fleet is
drained, a one-off `revoke-legacy-tokens` CLI verb (same run-then-exit shape as
`migrate`) performs the `UPDATE`. At that point no reader can misinterpret it,

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 Badge Make legacy revocation repeat-safe

The new verb is described only as performing an UPDATE, without pinning its predicate or repeat behavior. An implementation that updates every epoch-0 row on each invocation will overwrite historical RevokedAt values and generate table-wide WAL again whenever an operator retries the command. Define it as updating only IssuedEpoch == 0 && RevokedAt IS NULL, preserve existing timestamps, and test that a second run changes zero rows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ea9820e. Predicate pinned to IssuedEpoch = 0 AND RevokedAt IS NULL, existing timestamps preserved, and a test asserting a second run changes zero rows. Operators retry one-off verbs — seed and bootstrap-admin are idempotent for exactly this reason — so a verb that rewrote RevokedAt history and regenerated table-wide WAL on every retry would have been found the hard way.

…fety

Codex round 8. One design finding, three operational.

The design one answers a question I had asked it: is deploy B strictly
additive over A? No. The disabled-state checks in LoginAsync,
RefreshAsync and StepUpGrantService.IssueAsync were scheduled with the
mutations in B, so during B's own gradual rollout a disabled user's
login or refresh landing on an A-only replica mints a CURRENT-epoch
token that every B replica then honours — the suspension bypassed by a
credential issued after it. All disabled-state readers move to A, with
DisabledAt shipping unused.

That is the general rule the last several rounds kept circling from
different directions, and it is worth stating once: readers ship before
writers, always. A reader deployed early is inert; a writer deployed
early is a hole. Applied one deploy later, the same logic means B's API
must be staged fleet-wide before the SPA is exposed, or a browser served
the new UI by a B replica gets 404s from an A replica without the route.

Operational, now specified rather than waved at:

  - The additive migration still takes ACCESS EXCLUSIVE per ADD COLUMN.
    A long transaction on refresh_tokens from a still-serving old
    process makes the migrate job wait, and its queued exclusive request
    blocks login and refresh behind it. DatabaseReadyHealthCheck gates
    the replacement process, not the old fleet taking traffic. Short
    lock_timeout, fail and retry: a failed migrate job is a deploy that
    did not start; an unbounded lock wait is a deploy that took auth
    down.
  - revoke-legacy-tokens gets a pinned predicate and repeat-safety, so a
    retry does not rewrite RevokedAt history or regenerate table-wide
    WAL. Operators retry one-off verbs — seed and bootstrap-admin are
    idempotent for the same reason.

Also added a closing section on where this stops. Rounds 1-4 found
mechanism defects, 5-8 found deploy-boundary defects, and round 8 is the
first where most findings are hygiene rather than anything that would
make the shipped feature wrong. The core design has not been challenged
since round 3. What remains open is deployment procedure, which belongs
in the deployment/ops repo — this spec states requirements and should
not grow into a runbook.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex all four round-8 findings addressed in ea9820e; replies on each thread. The reader/writer split one was a real design defect and changed the issue breakdown (#364 now carries DisabledAt and every disabled-state check; #356 keeps only mutations and UI).

Calling the design frozen here, and saying why rather than just going quiet.

Rounds 1–4 found defects in the mechanism itself — it did not do what it claimed. Rounds 5–8 found defects at the deploy boundary — the mechanism was right, the old world had not gone away. Round 8 is the first where three of four findings are ordinary operational hygiene (a lock timeout, a verb predicate, a rollout order) rather than anything that would make the shipped feature wrong.

The core design has not been challenged since round 3: the epoch, its binding to both credential types, the check's position ahead of the grace branch, the account-row lock, the four-column email write, and the guards have all survived untouched. Nine drafts is where a design document stops earning its keep.

What genuinely remains open is deployment procedure — drains, staging order, lock timeouts, rollback steps. Per this repo's host-agnostic boundary that belongs in the deployment/ops repo as procedure, not here as prose, and I would rather move it than keep growing this into a runbook.

So: one final pass, please, restricted to anything that would make the shipped feature incorrect — a wrong guarantee, a missing guard, a race, a test that would pass with the defect present. If the only remaining findings are deployment procedure, say so and I will treat that as converged and take them to the ops repo.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea9820ee23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +513 to +517
So the order is: resolve the token, **load the user and compare epochs**, and
only then enter the grace/replay branch. An epoch mismatch fails closed *and
inert* — the generic `Identity.InvalidRefreshToken`, with **no** family
revocation, because a superseded token is evidence about a session that is
already dead and says nothing about the live one. Family revocation is preserved

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 Badge Scope replay revocation to the checked epoch

Fresh evidence beyond the earlier placement fix is that this comparison is only a point-in-time read: a revoked epoch-E token can pass it, then a password/email/disable transaction can commit epoch E+1 and a fresh login can mint an E+1 token before the old request reaches the currently user-wide RevokeAllActiveForUserAsync. That stale request then revokes the new family despite having passed the check against the old epoch; the proposed test on lines 644–649 is sequential and would pass with this race present. Scope replay-triggered revocation to IssuedEpoch == stored.IssuedEpoch (or serialize it with epoch bumps), and add a barrier-controlled test that pauses after the comparison, bumps the epoch and logs in, then resumes the replay.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in d88b151. This is a genuine race and it is distinct from the placement fix, so the freeze I called a comment ago is retracted rather than quietly amended.

Moving the comparison ahead of the grace branch fixed the case where the check never ran. It does nothing for the case where the check runs and passes and the world moves underneath it: read IssuedEpoch = E against CredentialEpoch = E, pass, and before the request reaches RevokeAllActiveForUserAsync a disable/email/password change commits E+1 and a fresh login mints an E+1 family — which the stale request then burns. Same denial of service, entered by passing the check rather than skipping it.

Replay revocation is now predicated on IssuedEpoch = stored.IssuedEpoch rather than UserId alone. That is also the semantically correct answer independent of the race: theft detection should burn the compromised family, not one issued after the compromise was already answered by a rotation.

You are right that the test I had written would pass with this present — it is sequential. Replaced with a barrier-controlled one: present the revoked epoch-E token, pause after the comparison succeeds, bump and log in fresh at E+1, then release; the E+1 family must survive.

The rule I took from it, since it is a different mistake from the placement one: a check and the action it authorizes must be scoped to the same thing. Moving a guard earlier makes it run; it does not make what follows obey it. If the guard reads one row and the action writes a set, the set has to be narrowed to what the guard actually vouched for.

Comment on lines +701 to +705
1. **The credential epoch, inert** (new issue). `CredentialEpoch`,
`IssuedEpoch`, the migration and cutover, the claim, the middleware,
`RefreshAsync`'s comparison, and the SPA plumbing that carries a 401's reason
through auth teardown. Makes no new promise: the only epoch bumps are the
password-reset paths that already revoke refresh tokens today.

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 Badge Put disabled-state readers in the Deploy A checklist

Fresh evidence after the round-8 fix is that the canonical Delivery breakdown still assigns the first PR only the epoch fields, middleware, and refresh comparison, omitting DisabledAt and the disabled-state checks in LoginAsync, RefreshAsync, and StepUpGrantService.IssueAsync. If implementation scope is taken from this issue breakdown, Deploy A will lack those readers and the gradual Deploy B rollout can again mint a current-epoch credential for a disabled user on an A replica. Move the column and all three readers into item 1/#364 so this checklist agrees with the hard rollout requirement above.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d88b151. You are right and this is the more embarrassing of the two: the previous round moved the readers into deploy A in the Rolling deploys section and left the canonical Delivery checklist listing only the epoch fields, middleware and refresh comparison — so anyone scoping implementation from the checklist, which is the section people actually work from, would have rebuilt the exact hole.

Item 1 now reads CredentialEpoch, IssuedEpoch, DisabledAt/DisabledBy, the additive migration, the claim, the middleware, the epoch comparison and epoch-scoped replay revocation, the SPA 401 plumbing, and all three disabled-state readers (LoginAsync, RefreshAsync, StepUpGrantService.IssueAsync), with a note that this is not optional detail and why. #364's issue body carries the same correction.

Recorded as its own item: a document that states a constraint in one section and contradicts it in the section people work from has not fixed anything.

…checklist

Codex round 9, after I had called the design frozen. That call was
premature and one of these is a genuine race, so the freeze is retracted
rather than quietly amended.

Moving the epoch comparison ahead of the grace branch fixed the case
where the check never ran. It did not fix the case where the check runs
and PASSES and the world moves underneath it: a request reads
IssuedEpoch = E against CredentialEpoch = E, passes, and before it
reaches RevokeAllActiveForUserAsync a disable/email/password change
commits E+1 and a fresh login mints an E+1 family — which the stale
request then burns. The same denial of service as before, entered by
passing the check rather than by skipping it.

Replay revocation is now predicated on IssuedEpoch = stored.IssuedEpoch
rather than on UserId alone. That is the semantically correct answer
independent of the race: theft detection should burn the compromised
family, not one issued after the compromise was already answered by a
rotation. The test has to be barrier-controlled — pause after the
comparison succeeds, bump and re-login, then release — because the
sequential version passes with the defect fully present.

The rule, since this is a different mistake from the placement one: a
check and the action it authorizes must be scoped to the same thing.
Moving a guard earlier makes it run; it does not make what follows obey
it. If the guard reads one row and the action writes a set, the set has
to be narrowed to what the guard actually vouched for.

Second finding: the Delivery checklist still listed deploy A as only the
epoch fields, middleware and refresh comparison, contradicting the
rolling-deploy section that had just moved DisabledAt and all three
disabled-state readers into it. An implementer scoping from the
checklist would have rebuilt the exact hole. A document that states a
constraint in one section and contradicts it in the section people work
from has not fixed anything.
@mforce

mforce commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

@codex both round-9 findings addressed in d88b151. Replies on each thread.

Retracting the freeze I called last comment. I asked for a pass restricted to anything that would make the shipped feature wrong, and you returned a genuine race — so the answer to "is this converged?" was no, and the filter did its job. Recorded in the spec rather than quietly amended.

  • Epoch-scoped replay revocation (P2, but really the same severity as the round-3 placement finding): passing the comparison is not enough when the action it authorizes is user-wide. Revocation is now predicated on IssuedEpoch = stored.IssuedEpoch, with a barrier-controlled test replacing the sequential one that would have passed with the defect present. Reachable as of deploy A, since the password-reset paths already bump the epoch.
  • Delivery checklist contradiction: the rolling-deploy section had the readers in A, the checklist people actually scope from did not. Fixed in both the spec and Credential epoch: per-request revocation check, deployed inert ahead of the user-admin mutations #364.

Same request as before, and it is a real question rather than a formality: another pass restricted to anything that would make the shipped feature incorrect — a wrong guarantee, a missing guard, a race, or a test that would pass with the defect present. Two specific seams, since those are where the last five rounds have lived:

  1. Now that revocation is epoch-scoped, is there a legitimate theft case it no longer catches — a real replay whose family should burn but now survives because the epoch moved between mint and detection?
  2. Are there other places in this design where a check reads one row and the action writes a set? The account-row FOR UPDATE guard and the bulk refresh revocation on disable both have that shape.

If the remaining findings are deployment procedure rather than correctness, say so and I will take them to the ops repo and stop iterating here.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: d88b151e5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mforce
mforce merged commit ca76bc8 into main Aug 2, 2026
9 checks passed
@mforce
mforce deleted the spec/user-disable-and-email branch August 2, 2026 03:26
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.

1 participant