From 51c13d1c5b16d562e45e8b3cbb671967508d9019 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 13:36:35 -0700 Subject: [PATCH 1/7] Record Fizzy's status against the 37signals OAuth Standard Points to bc3's doc/oauth/standard.md and gives Fizzy's status by requirement id across the OAuth train, with what's still missing. --- docs/oauth.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/oauth.md diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 0000000000..c868a8f5ed --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,79 @@ +# OAuth: Fizzy against the 37signals OAuth Standard + +The standard is bc3's [`doc/oauth/standard.md`](https://github.com/basecamp/bc3/blob/master/doc/oauth/standard.md), drafted in basecamp/bc3#13882. It is one bar for every 37signals authorization server. Each app meets it natively, and the shared checker (`basecamp-mcp-server` `cmd/mcp-authserver-verify`) proves it. This file records where Fizzy stands, by requirement id. + +Fizzy is becoming a full OAuth authorization server. Legacy personal access tokens become a degenerate case: one-off OAuth access tokens (LG-1). Fizzy is an existing app, and its shipped clients' PATs and session cookies keep working (#3191), so it migrates at their pace. A ✗ here is migration debt, not a violation. + +Status is `main` as of 2026-10-15: +- ✓ met +- ◐ partly met, or in an open PR +- ✗ not met +- ? not yet assessed +- — doesn't apply + +Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160, and it is integrated for beta on `oauth-integration-2026-10-15`. Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. + +## Status + +| Id | Status | Note | +|---|---|---| +| SE-1, SE-2 | ? | Sessions and magic links. Whether sign-out-everywhere ends native app sessions has not been assessed. | +| SE-3 | ◐ | OAuth grants are separate from sessions (#2296). | +| SE-4 / SE-4t | — | No passwords (magic links). | +| GP-1, GP-2, GP-6, GP-7 | ◐ | #2296: code only, S256 only, `iss`, `state` optional. | +| GP-3 | ◐ | #2296. | +| GP-4 | ◐ | Stateless codes with a jti; a revoke inside the 60s doesn't stop redemption (no tombstone). | +| GP-5 | ✗ | No revocation on code replay. | +| GP-8 | — | Declined: no consumer. | +| GP-9 | ✗ | PAR. | +| GP-10 | ✗ | Token exchange (Phase C). | +| NA-* | — | No first-party native OAuth clients yet. Any future native client starts at the bar. | +| RT-1 | ◐ | #3081 | +| RT-2 | ◐ | #3160 | +| RT-3 | ◐ | #3160 / #3161 | +| RT-4 | ◐ | #3161 | +| RT-5 | ◐ | #3081 | +| CR-1 | ◐ | DCR only, no registry or CIMD. | +| CR-2 | ◐ | #3082 | +| CR-3 | ◐ | #3080, #3159. No global registration cap. | +| CR-4 | ✗ | CIMD (Phase C′). | +| CR-5, CR-6 | ◐ | #2296, #3080 | +| DS-1, DS-2 | ◐ | #2296 | +| DS-3 | ◐ | #3158 (decision D6 open). | +| DS-4 | ✗ | | +| TK-1 | ◐ | #3163: `fizzy_pat_`, `fizzy_at_`, `fizzy_rt_`. No `fizzy_cs_`, and not registered with GitHub secret scanning. | +| TK-2 | ◐ | #3192 is step 1 of 3 (read by digest, plaintext fallback). Storing plaintext was an oversight; the conformance ledger's "Deliberate" entry is withdrawn. Steps 2 and 3 remove the fallback and the plaintext columns. The token-column index comes with step 1. | +| TK-3 | ✗ | Pepper keyring. | +| TK-4 | ◐ | #3081 | +| TK-5 | ◐ | `read`/`write` only. | +| TK-6 | ✗ | Audience and account binding (Phase B). Required before any MCP-audienced token. | +| TK-7 | ✗ | DPoP (Phase C′). | +| TK-8 | ◐ | | +| CX-1 | ◐ | Consent design for self-registered clients (decision D5) is open. | +| CX-2, CX-3 | ◐ | #2296 | +| CX-4 | ◐ | #3169 | +| CX-5, DX-1..2 | ✗ | | +| LG-1 | ✗ | PATs live in their own table (`identity_access_tokens`). | +| LG-2 | ✗ | Migrate them in place, keeping reach, non-expiry and the token string. Pinned by #3191. | +| SC-1 | ◐ | Per-IP limits only: token 20/min, revocation 20/min, registration 10/min. | +| SC-2, SC-3 | ✗ | Per-client limits after authentication, and platform pacing (Phase C). | +| SC-4 | ✗ | | +| SC-5 | ◐ | | +| SC-6 | ◐ | | +| SC-7 | ◐ | Switches are off by default (#2296). | +| AU-1..3, OB-1..2 | ✗ | No events or metrics in the OAuth code. | +| OP-1 | ◐ | #2296: `OAUTH_ACCEPTANCE_ENABLED`, `OAUTH_ISSUANCE_ENABLED`, `OAUTH_PILOT_CLIENT_IDS`. | +| OP-2 | ✗ | | +| OP-3 | ◐ | #3159, #3161 | +| OP-4, OP-5 | ✗ | | +| OP-6 | ◐ | This file, plus #3159's ledger. | + +The grant-surface rule is an opt-in per controller (`disallow_oauth_grants`). It should become deny-by-default, so that a new credential surface can't leak an OAuth grant's reach. + +## Order of work + +1. Land the train in order, through the beta integration branch, with migrations run by hand on beta. Put the AU-1 and OB-1 instrumentation at its base. +2. Finish #3192's three steps (TK-2), then TK-3. +3. LG-1 and LG-2: migrate PATs in place. +4. Phase B (audience and account binding), Phase C (token exchange, per-client limits and pacing), Phase C′ (DPoP, CIMD). +5. CX-5, DX-1, OB-2, OP-2. From 9528feaba83d0ce6011dda548510df8b013cf9e4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 14:39:06 -0700 Subject: [PATCH 2/7] Call the OAuth doc a rubric, and point at bc3's doc/oauth/rubric.md bc3's doc was renamed and reworded from a standard of requirements to a design and conformance rubric. This file follows: the new path, and the bar and its checks in place of the standard and its requirements. The check ids are unchanged. --- docs/oauth.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index c868a8f5ed..ce070dfd45 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -1,8 +1,8 @@ -# OAuth: Fizzy against the 37signals OAuth Standard +# OAuth: where Fizzy stands against our OAuth rubric -The standard is bc3's [`doc/oauth/standard.md`](https://github.com/basecamp/bc3/blob/master/doc/oauth/standard.md), drafted in basecamp/bc3#13882. It is one bar for every 37signals authorization server. Each app meets it natively, and the shared checker (`basecamp-mcp-server` `cmd/mcp-authserver-verify`) proves it. This file records where Fizzy stands, by requirement id. +The bar is described in bc3's [`doc/oauth/rubric.md`](https://github.com/basecamp/bc3/blob/master/doc/oauth/rubric.md), drafted in basecamp/bc3#13882: one design and conformance rubric for OAuth across our apps' authorization servers. Each app builds to it natively, and the conformance suite (`basecamp-mcp-server` `cmd/mcp-authserver-verify`) shows where it stands. This file records where Fizzy stands, by check id. -Fizzy is becoming a full OAuth authorization server. Legacy personal access tokens become a degenerate case: one-off OAuth access tokens (LG-1). Fizzy is an existing app, and its shipped clients' PATs and session cookies keep working (#3191), so it migrates at their pace. A ✗ here is migration debt, not a violation. +Fizzy is becoming a full OAuth authorization server. Legacy personal access tokens become a degenerate case: one-off OAuth access tokens (LG-1). Fizzy is an existing app, and its shipped clients' PATs and session cookies keep working (#3191), so it migrates at their pace. A ✗ here is migration debt, not a failure. Status is `main` as of 2026-10-15: - ✓ met From f92d2bb8d273f5ed9379a7021694ff118a027ab0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 14:55:39 -0700 Subject: [PATCH 3/7] Correct Fizzy's OAuth status where reviewers found it wrong The train requires state, revokes the first redemption's grant on a replayed code, and already shares one token table between PATs and OAuth. --- docs/oauth.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index ce070dfd45..4bad208cba 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -20,10 +20,11 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | SE-1, SE-2 | ? | Sessions and magic links. Whether sign-out-everywhere ends native app sessions has not been assessed. | | SE-3 | ◐ | OAuth grants are separate from sessions (#2296). | | SE-4 / SE-4t | — | No passwords (magic links). | -| GP-1, GP-2, GP-6, GP-7 | ◐ | #2296: code only, S256 only, `iss`, `state` optional. | +| GP-1, GP-2, GP-6 | ◐ | #2296: code only, S256 only, `iss`. | +| GP-7 | ✗ | The train requires `state` (`Oauth::AuthorizationsController#validate_state`); the bar makes it optional. | | GP-3 | ◐ | #2296. | | GP-4 | ◐ | Stateless codes with a jti; a revoke inside the 60s doesn't stop redemption (no tombstone). | -| GP-5 | ✗ | No revocation on code replay. | +| GP-5 | ◐ | In the train, a replayed code destroys the grant its first redemption made (`Identity::AccessToken.redeem`), after client, redirect URI and PKCE are checked. | | GP-8 | — | Declined: no consumer. | | GP-9 | ✗ | PAR. | | GP-10 | ✗ | Token exchange (Phase C). | @@ -53,7 +54,7 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | CX-2, CX-3 | ◐ | #2296 | | CX-4 | ◐ | #3169 | | CX-5, DX-1..2 | ✗ | | -| LG-1 | ✗ | PATs live in their own table (`identity_access_tokens`). | +| LG-1 | ◐ | In the train, PATs and OAuth tokens share `Identity::AccessToken` (`personal` and `oauth` scopes, by `oauth_client_id`). What's left: PATs as grant records, listed with connected apps. | | LG-2 | ✗ | Migrate them in place, keeping reach, non-expiry and the token string. Pinned by #3191. | | SC-1 | ◐ | Per-IP limits only: token 20/min, revocation 20/min, registration 10/min. | | SC-2, SC-3 | ✗ | Per-client limits after authentication, and platform pacing (Phase C). | From 3becdc6e0dd236dd41dc33b7ae07e96943eaac2b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 00:14:04 -0700 Subject: [PATCH 4/7] Record optional state, token length, OAuth response headers and log filtering --- docs/oauth.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index 4bad208cba..ebb183f651 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -21,7 +21,7 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | SE-3 | ◐ | OAuth grants are separate from sessions (#2296). | | SE-4 / SE-4t | — | No passwords (magic links). | | GP-1, GP-2, GP-6 | ◐ | #2296: code only, S256 only, `iss`. | -| GP-7 | ✗ | The train requires `state` (`Oauth::AuthorizationsController#validate_state`); the bar makes it optional. | +| GP-7 | ◐ | #2296: `state` is optional; when sent it's one string of at most 2048 characters, echoed exactly (`Oauth::AuthorizationsController#validate_state`). | | GP-3 | ◐ | #2296. | | GP-4 | ◐ | Stateless codes with a jti; a revoke inside the 60s doesn't stop redemption (no tombstone). | | GP-5 | ◐ | In the train, a replayed code destroys the grant its first redemption made (`Identity::AccessToken.redeem`), after client, redirect URI and PKCE are checked. | @@ -42,7 +42,7 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | DS-1, DS-2 | ◐ | #2296 | | DS-3 | ◐ | #3158 (decision D6 open). | | DS-4 | ✗ | | -| TK-1 | ◐ | #3163: `fizzy_pat_`, `fizzy_at_`, `fizzy_rt_`. No `fizzy_cs_`, and not registered with GitHub secret scanning. | +| TK-1 | ◐ | #3163: `fizzy_pat_`, `fizzy_at_`, `fizzy_rt_`, `fizzy_cs_`, each with 44 base58 characters (257 bits). Codes are stateless and unprefixed (GP-4). Not registered with GitHub secret scanning. | | TK-2 | ◐ | #3192 is step 1 of 3 (read by digest, plaintext fallback). Storing plaintext was an oversight; the conformance ledger's "Deliberate" entry is withdrawn. Steps 2 and 3 remove the fallback and the plaintext columns. The token-column index comes with step 1. | | TK-3 | ✗ | Pepper keyring. | | TK-4 | ◐ | #3081 | @@ -59,8 +59,8 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | SC-1 | ◐ | Per-IP limits only: token 20/min, revocation 20/min, registration 10/min. | | SC-2, SC-3 | ✗ | Per-client limits after authentication, and platform pacing (Phase C). | | SC-4 | ✗ | | -| SC-5 | ◐ | | -| SC-6 | ◐ | | +| SC-5 | ◐ | #3082: every OAuth response carries `Cache-Control: no-store` and `Referrer-Policy: no-referrer` (`Oauth::BaseController`). Consent has CSRF protection and a `form-action` limited to the validated redirect. | +| SC-6 | ◐ | #2296 filters `code`, `code_verifier`, `device_code`, `client_assertion` and `dpop` beside the existing `token` and `secret` matches, the logged redirect included. SQL and slow-query logs are separate work. | | SC-7 | ◐ | Switches are off by default (#2296). | | AU-1..3, OB-1..2 | ✗ | No events or metrics in the OAuth code. | | OP-1 | ◐ | #2296: `OAUTH_ACCEPTANCE_ENABLED`, `OAUTH_ISSUANCE_ENABLED`, `OAUTH_PILOT_CLIENT_IDS`. | From 38c439418e71c4d856fe1f25522492f2ea667bad Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 01:24:50 -0700 Subject: [PATCH 5/7] Say which of the train the integration branch carries, and what stacks on #3192 --- docs/oauth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oauth.md b/docs/oauth.md index ebb183f651..cd17b0ecf9 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -11,7 +11,7 @@ Status is `main` as of 2026-10-15: - ? not yet assessed - — doesn't apply -Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160, and it is integrated for beta on `oauth-integration-2026-10-15`. Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. +Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160. `oauth-integration-2026-10-15` carries #2296 through #3169, #3158 and #3191 for the manual beta migrations; #3192 and its three digest migrations still have to be merged into it before they run on beta. Stacked on #3192: #3204 (AU-1, OB-1), #3205 (LG-1, LG-2), and the D2 and D3 drafts (TK-2). Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. ## Status From c5fd409d65904b728d2886dfb11e7ed67ad427b4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 02:01:22 -0700 Subject: [PATCH 6/7] Mark LG-1, LG-2, AU-1 and OB-1 as in review, and name the D2 and D3 drafts --- docs/oauth.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index cd17b0ecf9..3511aae217 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -11,7 +11,7 @@ Status is `main` as of 2026-10-15: - ? not yet assessed - — doesn't apply -Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160. `oauth-integration-2026-10-15` carries #2296 through #3169, #3158 and #3191 for the manual beta migrations; #3192 and its three digest migrations still have to be merged into it before they run on beta. Stacked on #3192: #3204 (AU-1, OB-1), #3205 (LG-1, LG-2), and the D2 and D3 drafts (TK-2). Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. +Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160. `oauth-integration-2026-10-15` carries #2296 through #3169, #3158 and #3191 for the manual beta migrations; #3192 and its three digest migrations still have to be merged into it before they run on beta. Stacked on #3192: #3204 (AU-1, OB-1), #3205 (LG-1, LG-2), and the D2 and D3 drafts #3202 and #3203 (TK-2), gated on production running D1 and then D2. Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. ## Status @@ -43,7 +43,7 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | DS-3 | ◐ | #3158 (decision D6 open). | | DS-4 | ✗ | | | TK-1 | ◐ | #3163: `fizzy_pat_`, `fizzy_at_`, `fizzy_rt_`, `fizzy_cs_`, each with 44 base58 characters (257 bits). Codes are stateless and unprefixed (GP-4). Not registered with GitHub secret scanning. | -| TK-2 | ◐ | #3192 is step 1 of 3 (read by digest, plaintext fallback). Storing plaintext was an oversight; the conformance ledger's "Deliberate" entry is withdrawn. Steps 2 and 3 remove the fallback and the plaintext columns. The token-column index comes with step 1. | +| TK-2 | ◐ | #3192 is step 1 of 3 (read by digest, plaintext fallback). Storing plaintext was an oversight; the conformance ledger's "Deliberate" entry is withdrawn. Steps 2 and 3 (#3202, #3203, drafts) remove the fallback and the plaintext column. The token-column index comes with step 1. | | TK-3 | ✗ | Pepper keyring. | | TK-4 | ◐ | #3081 | | TK-5 | ◐ | `read`/`write` only. | @@ -54,15 +54,16 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | CX-2, CX-3 | ◐ | #2296 | | CX-4 | ◐ | #3169 | | CX-5, DX-1..2 | ✗ | | -| LG-1 | ◐ | In the train, PATs and OAuth tokens share `Identity::AccessToken` (`personal` and `oauth` scopes, by `oauth_client_id`). What's left: PATs as grant records, listed with connected apps. | -| LG-2 | ✗ | Migrate them in place, keeping reach, non-expiry and the token string. Pinned by #3191. | +| LG-1 | ◐ | #3205 issues PATs from a first-party `fizzy-personal-access` client in the same digest-stored table. Listing them in Connected Apps is an open product call. | +| LG-2 | ◐ | #3205 backfills existing PATs onto that client in place, keeping reach, non-expiry and the token string. Pinned by #3191. | | SC-1 | ◐ | Per-IP limits only: token 20/min, revocation 20/min, registration 10/min. | | SC-2, SC-3 | ✗ | Per-client limits after authentication, and platform pacing (Phase C). | | SC-4 | ✗ | | | SC-5 | ◐ | #3082: every OAuth response carries `Cache-Control: no-store` and `Referrer-Policy: no-referrer` (`Oauth::BaseController`). Consent has CSRF protection and a `form-action` limited to the validated redirect. | | SC-6 | ◐ | #2296 filters `code`, `code_verifier`, `device_code`, `client_assertion` and `dpop` beside the existing `token` and `secret` matches, the logged redirect included. SQL and slow-query logs are separate work. | | SC-7 | ◐ | Switches are off by default (#2296). | -| AU-1..3, OB-1..2 | ✗ | No events or metrics in the OAuth code. | +| AU-1, OB-1 | ◐ | #3204: lifecycle events through `Rails.event`, logged as structured lines in SaaS, and a Yabeda `oauth` group under bc3's names. | +| AU-2, AU-3, OB-2 | ✗ | | | OP-1 | ◐ | #2296: `OAUTH_ACCEPTANCE_ENABLED`, `OAUTH_ISSUANCE_ENABLED`, `OAUTH_PILOT_CLIENT_IDS`. | | OP-2 | ✗ | | | OP-3 | ◐ | #3159, #3161 | From 6d308f2fadb8612f9e349de11ef261d36bcb50f9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 17 Oct 2026 02:23:51 -0700 Subject: [PATCH 7/7] Record the D6 and RT-5 deviations, #3206, and the linear train --- docs/oauth.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index 3511aae217..3358fbf8b3 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -11,7 +11,7 @@ Status is `main` as of 2026-10-15: - ? not yet assessed - — doesn't apply -Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192, with #3158 beside #3160. `oauth-integration-2026-10-15` carries #2296 through #3169, #3158 and #3191 for the manual beta migrations; #3192 and its three digest migrations still have to be merged into it before they run on beta. Stacked on #3192: #3204 (AU-1, OB-1), #3205 (LG-1, LG-2), and the D2 and D3 drafts #3202 and #3203 (TK-2), gated on production running D1 and then D2. Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. +Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #3158 → #3160 → #3161 → #3159 → #3163 → #3169 → #3192. `oauth-integration-2026-10-15` carries #2296 through #3169, #3158 and #3191 for the manual beta migrations; #3192 and its three digest migrations still have to be merged into it before they run on beta. Stacked on #3192: #3204 (AU-1, OB-1), #3205 (LG-1, LG-2), #3206 (GP-4, GP-5), and the D2 and D3 drafts #3202 and #3203 (TK-2), gated on production running D1 and then D2. Fizzy's own RFC ledger is `docs/oauth/spec_conformance.md` on #3159. ## Status @@ -23,8 +23,8 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | GP-1, GP-2, GP-6 | ◐ | #2296: code only, S256 only, `iss`. | | GP-7 | ◐ | #2296: `state` is optional; when sent it's one string of at most 2048 characters, echoed exactly (`Oauth::AuthorizationsController#validate_state`). | | GP-3 | ◐ | #2296. | -| GP-4 | ◐ | Stateless codes with a jti; a revoke inside the 60s doesn't stop redemption (no tombstone). | -| GP-5 | ◐ | In the train, a replayed code destroys the grant its first redemption made (`Identity::AccessToken.redeem`), after client, redirect URI and PKCE are checked. | +| GP-4 | ◐ | Stateless codes with a jti, single-use by a unique record of each redeemed jti, kept 25 hours (#3206). Not yet bound to `resource` (Phase B). | +| GP-5 | ◐ | A replayed code destroys the grant its first redemption made (`Identity::AccessToken.redeem`), after client, redirect URI and PKCE are checked; #3206 keeps the code on record, so it mints nothing after its grant is gone. | | GP-8 | — | Declined: no consumer. | | GP-9 | ✗ | PAR. | | GP-10 | ✗ | Token exchange (Phase C). | @@ -33,14 +33,14 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | RT-2 | ◐ | #3160 | | RT-3 | ◐ | #3160 / #3161 | | RT-4 | ◐ | #3161 | -| RT-5 | ◐ | #3081 | +| RT-5 | ◐ | #3081: narrows, never widens. **Deviation:** a grant is one row, so narrowing on refresh narrows the grant for good, where RFC 6749 §6 would have the new refresh token keep its predecessor's scope. That's strictly more restrictive than the rubric (which forbids only widening), and authorizing the app again restores a broader scope. No column added. | | CR-1 | ◐ | DCR only, no registry or CIMD. | | CR-2 | ◐ | #3082 | | CR-3 | ◐ | #3080, #3159. No global registration cap. | | CR-4 | ✗ | CIMD (Phase C′). | | CR-5, CR-6 | ◐ | #2296, #3080 | | DS-1, DS-2 | ◐ | #2296 | -| DS-3 | ◐ | #3158 (decision D6 open). | +| DS-3 | ◐ | #3158: an invalid or expired Bearer token gets 401 `invalid_token`; an OAuth grant without write gets 403 `insufficient_scope`. **Deviations for shipped clients** (the native apps and fizzy-cli, pinned by #3191): a JSON request with no credentials keeps its 302 to sign in, since nothing in it tells a shipped client from an OAuth one; and a read-only personal access token writing keeps its 401, now with an `insufficient_scope` challenge. Hosted OAuth clients discover through fizzy-mcp-server's 401 + `resource_metadata` and Fizzy's `/.well-known` metadata, so this costs them little. **Exit:** once the oldest supported native app and fizzy-cli handle a 401 challenge there. | | DS-4 | ✗ | | | TK-1 | ◐ | #3163: `fizzy_pat_`, `fizzy_at_`, `fizzy_rt_`, `fizzy_cs_`, each with 44 base58 characters (257 bits). Codes are stateless and unprefixed (GP-4). Not registered with GitHub secret scanning. | | TK-2 | ◐ | #3192 is step 1 of 3 (read by digest, plaintext fallback). Storing plaintext was an oversight; the conformance ledger's "Deliberate" entry is withdrawn. Steps 2 and 3 (#3202, #3203, drafts) remove the fallback and the plaintext column. The token-column index comes with step 1. | @@ -54,7 +54,7 @@ Main has no OAuth yet. The train is #2296 → #3080 → #3081 → #3082 → #316 | CX-2, CX-3 | ◐ | #2296 | | CX-4 | ◐ | #3169 | | CX-5, DX-1..2 | ✗ | | -| LG-1 | ◐ | #3205 issues PATs from a first-party `fizzy-personal-access` client in the same digest-stored table. Listing them in Connected Apps is an open product call. | +| LG-1 | ◐ | #3205 issues PATs from a first-party `fizzy-personal-access` client in the same digest-stored table. They stay on `/my/access_tokens`, not in Connected Apps (LG-1 allows either), and keep the `fizzy_pat_` prefix for secret scanning (TK-1). | | LG-2 | ◐ | #3205 backfills existing PATs onto that client in place, keeping reach, non-expiry and the token string. Pinned by #3191. | | SC-1 | ◐ | Per-IP limits only: token 20/min, revocation 20/min, registration 10/min. | | SC-2, SC-3 | ✗ | Per-client limits after authentication, and platform pacing (Phase C). |