Conversation
A missing keychain entitlement (OSStatus -34018) surfaced to members as "Unable to connect. Please try again in a moment.", which pointed members and operators at connectivity instead of the real signing defect. Unknown programming faults fell into the same bucket. classifyAuthFailure now inspects code, message and name together, routes security-configuration faults to KEYCHAIN, matches genuine transport faults explicitly, and returns a new bounded UNKNOWN category instead of defaulting to NETWORK. Network copy is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGE4hGQn7cJiRBAP1o44B
A revoked credential cleared the in-memory token but never told SiteManager, so the persisted site record kept the dead token and the root navigator stayed AUTHENTICATED. The member was stranded on "Your saved profile could not be loaded" with a Try again button that reissued the same doomed request, and a relaunch rehydrated the dead token and repeated it. Only deleting the app recovered. Site.retireCredential now latches the retirement and notifies the manager, which persists the cleared record, drops the keychain token, authorization profile, avatar authority and cached authenticated GETs, and notifies subscribers so the root falls back to the signed-out welcome screen. Retirement remains driven solely by classifyAuthResponse's authoritative 401. Ordinary 403 authorization limits, onboarding/policy gating, 429 cooldowns, offline failures and 5xx errors continue to preserve the session. A stale auth completion can no longer restore a token retired while it was in flight, and a verified fresh authorization clears the latch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGE4hGQn7cJiRBAP1o44B
Discourse answers a revoked or deleted User API key with 403, the same status an ordinary permission denial uses, so the previous 401-only rule never fired and the member stayed stranded on the authenticated bootstrap error. A forbidden response now retires the credential only on canonical evidence: not_logged_in is unambiguous and retires immediately, while the shared invalid_access is resolved by asking /session/current.json - an endpoint every authorized client can reach - whether the credential still authenticates. Preserved by construction: onboarding and policy gating (they carry reason or continue_at and are never probed), ordinary permission denials with a live credential, 429 cooldowns, 5xx failures and offline transport errors, all of which leave the session intact. The probe is a bare fetch so it cannot recurse through this branch, and it is de-duplicated per site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGE4hGQn7cJiRBAP1o44B
… alone" This reverts commit a767ed5.
* Classify official Floor notices separately * Format Floor attention classification
…ive lineage (#8) * fix(auth): start every authorization in an ephemeral browser session A non-ephemeral ASWebAuthenticationSession shares the system Safari data store. Deleting the app clears the container, AsyncStorage, the client ID, the RSA keys and the stored User API Key, but not that shared cookie jar, so a reinstalled app presented an already-authenticated Discourse session and bound the new User API Key to the previous account without prompting. Physical TestFlight Build 8 production testing confirmed the auth session reused qa_test instead of allowing cert_probe_01 to sign in (server-side evidence commit 35d9d27). The native bridge already accepted an ephemeral argument and applied it as prefersEphemeralWebBrowserSession. The defect was entirely in JavaScript: site_manager passed a hardcoded false and requestIOSAuth defaulted to false. EPHEMERAL_AUTH_SESSION is now a module constant rather than a caller-supplied parameter, so no call site can reintroduce a persistent session. The adjusternetwork://auth_redirect callback contract, the one-shot nonce/client-ID binding, the canonical-origin admission check, and the fail-closed channel-derived environment resolution are unchanged. OTA-shippable on runtime an-ios-android-1.0.0-native-2: the shipped Build 8 executable already exports requestAuth:callbackURLScheme:ephemeral:resolver: rejecter: and references setPrefersEphemeralWebBrowserSession:, so the JS/native interface is unchanged and no new binary is required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR * docs(ota): record the stale production group reference POST-SUBMISSION-HOLD.md still names group 39eb1e9b-8b72-480b-99f1-f52ad6d351fc as production. A read-only channel check on 2026-09-02 shows production now serves group 70eebadf-5736-4cd6-a7db-2980a69f0494 at source fdb8314, published 2026-09-01. That document is the stated rollback target during the Apple-review freeze, so the stale value is worth correcting. The hold document is untracked in this lineage, so the correction is recorded in the fix lane's backlog rather than applied to it. Documentation only; it does not affect the staged OTA, which was published from fdd5963. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR * fix(auth): make account selection independent of browser session state The ephemeral browser-auth session alone did not resolve the stale-identity failure on a physical device, and it cannot: it is not the only carrier of a previous member identity, and the app cannot observe whether iOS honoured it. Three carriers survive an app delete and reinstall: - the Keychain User API Key and RSA material, which outlive the app container and are restored without any staleness check; - the shared Safari cookie jar, which app deletion never clears; - js/admissionHandoff.js, which opens the real Safari app through Linking.openURL, where prefersEphemeralWebBrowserSession has no effect. SiteManager.resetAuthorizationIdentity retires all of the client-side carriers together - browser cookies, stored User API Key, RSA material, the recorded authorization profile and the client ID - and the sign-in screen exposes it as "Use a different account". This does not depend on iOS honouring an ephemeral session and it never revokes a server-side credential. A staging-gated diagnostics block on the sign-in screen reports the active OTA git SHA, update ID, channel and embedded-versus-remote source, plus whether a Keychain credential survived reinstall, so bundle activation is provable in the product instead of over USB. It also opens the canonical session endpoint inside the same browser-auth context, which shows directly whether that context is anonymous. The gate is the trusted OTA channel, so the block cannot render on production. The ephemeral session is retained: it is correct, and it is the cheaper of the two mechanisms when iOS honours it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR * docs(backlog): record the Edit Profile photo defect and correct its diagnosis Physical testing found no avatar controls in the native Edit Profile screen. The native editor is in fact already built: NativeProfileScreen renders the current avatar, a Change photo action and a Remove photo action, backed by uploadProfilePhoto/removeProfilePhoto against /native/v1/profile/photo, which delegates to Discourse's own avatar system rather than a parallel one, with type validation, permission and error states, and immediate cross-surface refresh through avatarAuthority. The block is gated on the server capability card.photo.enabled, and POST-SUBMISSION-HOLD.md deliberately keeps structured_profile_photo_enabled false during Apple review. The controls were therefore absent because the capability is off, not because the UI is missing. Remaining work is the deferred server/privacy activation lane, one genuine native gap (camera capture, library picking only today), and a web parity audit that cannot be performed from this repository. Recorded only. No product code changed during auth certification. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
) The member identity was captured once, during handleAuthPayload's call to Site.refresh, and never refetched. Site.refresh is the only writer of site.username, it is reached only through SiteManager.refreshSites, and that loop was deliberately removed from the authenticated lifecycle to stop it extending rate limits: _refreshAuthenticatedResources refreshes notification state only, and the 30s timer calls refreshSites solely while signed out with no active site. The value therefore froze at authorization, and because it is persisted in AsyncStorage under @Discourse.sites, a relaunch rehydrated the same stale handle rather than refetching it. A server-side rename then left every username-dependent surface pointing at the old handle: /u/{username}.json and the bookmarks route 404, and the self-checks behind can-edit-own-card, Lounge own-message deletion, moderation and Ask idempotency all stop matching, so a renamed member silently loses those abilities. The Floor greeting was additionally never able to show a display name at all, because Site never assigned this.name and 'name' was absent from Site.FIELDS, so memberDisplayName always fell through to the title-cased handle. Site.refreshIdentity reads /session/current.json, the narrowest authenticated endpoint carrying both username and name and already covered by the granted session_info scope, so no server contract changes. SiteManager .refreshActiveIdentity refreshes the active site only, shares one in-flight request, persists solely on change, and swallows failures so a malformed payload or a lost connection preserves the last known identity instead of blanking it. The foreground lifecycle calls it inside the existing 30s guard; the retired multi-site loop stays retired. handleAuthPayload now clears the identity before binding a new credential and repopulates it once the authorization is accepted, so a newly authorized account cannot inherit the previous member's handle. logoff clears the name with the username. 'name' is additive in Site.FIELDS: existing records read it as undefined and repopulate on the first refresh, so no migration, logout or reinstall. Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…redential to the trusted origin (#10) * fix(avatars): authenticate private member photo requests Every member avatar in the app renders through one component - Avatar in js/product/ProductComponents.js, re-exported as MemberAvatar - and it passed source={{ uri }} with no headers. React Native's image loader runs its own native pipeline and attaches none of the app's credentials, so those requests went out unauthenticated. Only site.jsonApi sends User-Api-Key. Against the new /renaissance/member-photo/:username/:size/:version contract that means an ordinary member photo would resolve to the anonymous letter-avatar redirect rather than the member's bytes. memberImageSource attaches User-Api-Key and User-Api-Client-Id, but only when the resolved URL is on the trusted origin. An avatar_template can carry an absolute URL to an external host, and isCanonicalUrl rejects both foreign origins and plain HTTP, so the credential cannot leak to an arbitrary image host or travel unencrypted. It stays in request headers: never in the URL, query string, or cache key. A missing or unauthenticated source returns no headers, and Avatar's existing onError path falls back to the letter avatar, so a rejected request degrades rather than breaking a screen. resetAuthorizationIdentity now clears every cached avatar record, so switching accounts cannot render bytes that were resolved under the previous member's admission. Logout already cleared the per-site records through remove(). This reuses the credential pattern already certified for secure media in DiscourseMedia.js, with the origin guard that path does not currently apply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR * fix(media): bind the secure-media credential to the trusted origin SecureMediaImage and the attachment viewer attached User-Api-Key and User-Api-Client-Id to whatever URL the media item carried. Those URLs are derived from post cooked HTML by absoluteUrl(), which returns any absolute http(s) URL unchanged, so member-authored content could point an image or attachment at an external host and receive other members' User API keys. Both now use authenticatedOriginHeaders, the same guard the member-photo work introduced: the credential is attached only for the canonical HTTPS origin. Media semantics are unchanged - canonical-origin secure uploads still authenticate, and a pre-signed off-origin object never needed the header. The PDF viewer test built its site on the staging origin while the suite mocks the production OTA channel, so its own fixture was not the canonical origin. The fixture now matches the mocked channel, and a companion test proves the viewer refuses to authenticate an off-origin attachment. Adversarial coverage spans look-alike domains, subdomains, embedded-URL query and fragment tricks, credentials in the authority, a non-default port, plain HTTP, and non-HTTP schemes including data:, file: and javascript:. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Production regression from 5b6d988. avatar_template values are relative paths such as /user_avatar/adjusternetwork.org/<user>/120/1234_2.png, so resolving them against the site URL puts every avatar on the canonical origin. The origin guard then attached User-Api-Key to all of them, turning each rendered avatar into a counted user-API request. Those requests are issued by React Native's native image pipeline, which sits outside requestOrchestrator and its cooldowns, so they were invisible to the app's own rate limiting while still consuming the member's quota. A screen renders many avatars at once, so the quota drained and the real API calls behind them started failing. The device request ledger shows the result: repeated cooldown_begin on the global-user-api bucket with 4xx, and GET /latest.json, /native/v1/profile, /u/<user>.json and /chat/api/me/channels.json all settling as 4xx failures. Floor still rendered from its cached community snapshot; Discussions had no snapshot to fall back on, so it did not load. Only /renaissance/member-photo/ now carries the credential. Ordinary Discourse avatars load unauthenticated exactly as they did before 5b6d988, restoring the previous request cost. The private member-photo route keeps its credential and its origin and HTTPS guard, and secure media is untouched: it authenticated before this work, so its request count never changed. Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…failures P1 - Retry-After was silently discarded. RequestOrchestrator.beginCooldown called retryAfterDelayMs(response, retryIndex), but retryAfterDelayMs takes the Retry-After header VALUE, not a response, so it returned null on the first type check. The cooldown was then set to now() + null, which is now(), so it expired immediately and every subsequent request sailed straight through an active limiter window. That is the request amplification seen in production: 27 further requests issued while Retry-After was active, and 104 rejected 429s in a day. The production request ledger corroborates the mechanism precisely - every recorded cooldown_begin carried durationClass "short", which is what null < 10000 evaluates to. beginCooldown now uses rateLimitDelayMs, which reads the header off the response and falls back to a bounded backoff when it is absent. Cooldowns stay clamped to RATE_LIMIT_MAX_MS, so honoring a hostile or very long Retry-After cannot deadlock auth, logout or session recovery. site.jsonApi additionally waits on the IP bucket before issuing a request. An IP-scoped 429 previously set a cooldown that only retries consulted, so fresh requests kept amplifying inside that window too. P2 - a transient image failure latched the letter initial for the lifetime of the mounted Avatar. onError set failedUri, and the reset only ran when the URI changed, which it never does for a given member and size. Because stack screens remount on every navigation and the member-photo route sends Cache-Control: private, no-store, each mount issued a fresh authenticated request and a single 429 was terminal. Recovery is now bounded: two delayed retries at 1.5s and 6s, counted per URI, then the initial stands. The pending timer is cleared on unmount. P4 - member profile reads had no TTL, so remounting refetched /u/:username.json every navigation. They now coalesce inside a 15s TTL without serving stale. Chat message loads are deliberately left uncached because they are real-time. The private member-photo credential boundary is unchanged: only /renaissance/member-photo/ receives User API credentials, ordinary /user_avatar/ stays unauthenticated, and the canonical-origin and secure-media guards are untouched. Two pre-existing sitePrivacy tests asserted Retry-After waiting but could only pass vacuously while the cooldown was zero-length. They now exercise the real wait, so they advance timers instead of counting microtasks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
Adds the evidence the review asked for rather than asserting it: which bucket a user_api_key_limiter_60_secs response enters, that the user-api bucket is path independent so every authenticated jsonApi path waits on it, that the IP codes map to a genuinely separate bucket, that a different client id is not blocked, that every wait is clamped so auth and logout cannot deadlock, that mutations are never replayed, and what the 60s clamp actually does to a 136s directive. No implementation change.
The single RATE_LIMIT_MAX_MS ceiling conflated two different concerns. Clamping the cooldown to it meant a production Retry-After of 136s was recorded as 60s, so GET chains re-entered a known-active window at roughly 60s and 120s and generated avoidable 429s. Two ceilings now: - RATE_LIMIT_COOLDOWN_MAX_MS = 180000 bounds the server-directed cooldown lifetime, so a 136s directive is recorded in full. - RATE_LIMIT_MAX_MS = 60000 still bounds how long one request may block. waitForBucket fails fast with the existing bounded api_rate_limited error when the remaining cooldown exceeds the per-request ceiling, rather than sending a request into an active window or holding a caller for minutes. The cooldown is left intact, so later requests keep observing it until it genuinely expires; once the remainder falls inside the per-request ceiling it becomes a normal bounded wait again. Auth, logout and session recovery therefore cannot deadlock: nothing sleeps longer than 60s and the fail-fast path returns immediately. GET retry counts stay bounded, mutations are still never replayed, and a repeated 429 extends the shared window without fan-out. Also reverts the IP-bucket pre-request wait added earlier in this package. The proven production defect is the User API limiter; no IP-limiter incident has been observed, so the request path stays minimal to the evidence. The pre-existing IP bucket mapping and its tests are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
…14) * docs(release): record the native rate-limit resilience certification Captures the production certification of dad0af1: shipped OTA identifiers, the rollback pointer to the prior f662cf3 production group, the defects corrected, founder device results, and the server lane's read-only observation. Two earlier hypotheses are recorded as corrections so they are not repeated: message-bus does not carry User-Api-Key and does not consume the User API bucket, and real pre-change pressure came from chat/api, member-photo, /u/*.json and /native/v1/*. The scope limit is recorded plainly: no natural 429 occurred during the run, so Retry-After handling, the fail-fast path and avatar recovery remain code- and CI-validated rather than production-trigger validated. Also files the granted_badge notification defect as a separate P2. Type 12 does map to a badge endpoint in DiscourseUtils, so the fault is downstream of that mapping; it is not a regression from this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR * docs(ota): require provenance tags and record the superseded avatar diagnostic Adds a release-process requirement that every production OTA artifact receives an immutable annotated tag at promotion time, named ota-<group-short>-<sha-short> and carrying the group, platform update IDs, runtime, full SHA and rollback pointer. Squash and rebase merges both rewrite commits, so four shipped SHAs became unreachable from trunk before this rule existed. Also records the avatar-resolution diagnostic design that PR #12 carried, since that PR is closed as superseded rather than merged. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r flake (#17) Backlog #10. The logged-out suite has failed intermittently across trunk and unrelated pull requests, most recently on PR #16 with: Exceeded timeout of 120000 ms for a hook. The hook is the beforeEach in e2e/onboarding.test.js, which performs a full device.launchApp({ delete: true, newInstance: true }) reinstall. jest.config's testTimeout governs hooks as well as tests, so a cold macOS runner could exhaust the entire 120s budget inside the reinstall, before the element waits ever ran. The same run makes that plain: a sibling test asserting on the same welcome element passed at 119027ms, one second under the old budget, and the iPad job passed on identical product code. testTimeout goes to 180s, and the iPhone Detox job gains --retries 2 to match the iPad job that has always had it. That asymmetry is the only reason this flake blocked iPhone and not iPad. No assertion is weakened. The 15s and 30s element waits in loggedOutLaunch.js are unchanged, the logged-out-welcome-scroll checks remain, no sleeps were added, and a test that fails every retry still fails the job. Comments now record why the budget is what it is, so it is not trimmed back later. CI configuration only; no product code.
…ropping them (#16) Tapping an Autobiographer notification marked it read and then did nothing visible. The type-12 mapping was never the problem: DiscourseUtils produced a well-formed /badges/{id}/basic?username={user} URL from a payload that carries badge_id and username. The destination was lost one layer later. classifyFirstPartyMemberRoute is an allowlist of native screens and returned 'rejected' for anything outside it, and openUrl handled only 'native' and 'privileged_external'. A 'rejected' result therefore fell off the end of the function: no navigation, no error, no feedback. Read-marking had already fired, which is why the server saw /notifications/read return 200 while the UI stayed put. This was never badge-specific. The same silent drop affected group message summaries, consolidated likes, accepted membership requests, and both chat mention and chat message notifications - seven classes in total, chat included. A 'first_party_web' disposition now names valid canonical-origin member destinations that have no native screen, and openUrl sends them to the already-registered authenticated Discourse WebView. It is an explicit path allowlist, not a blanket "anything internal opens" rule, and it is evaluated after every native pattern and after the /admin boundary so it cannot widen an already-denied destination. Off-origin URLs, plain HTTP, unauthenticated callers, non-staff /admin, unknown types and empty endpoints all stay rejected, and a malformed badge payload fails safely because a non-numeric badge id does not match. openUrl now handles every disposition explicitly and records a security event for a denial, so no recognised disposition can silently fall through again. No native badge, group or chat screen was invented; the existing WebView destination is used. Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ember pages (#19) First-party member pages could not open in the app because the WebView carries cookies, not the User API key, and WebViewComponent's navigation policy correctly refuses to load a canonical page into an unauthenticated Discourse session. PR #16 routed there anyway, which produced a blank screen stuck on "Still loading...". The supported contract closes the gap. webViewSession posts the app's existing RSA public key to /user-api-key/otp with the governed auth_redirect and pkcs1 padding, using site.jsonApi so the existing User-Api-Key and User-Api-Client-Id headers, rate-limit buckets and cooldowns all apply. The returned redirect_url is parsed with the same helper the authorization callback uses and the one-time password is decrypted with the same JSEncrypt private key. No second cryptographic implementation, and no new server endpoint. The WebView then loads /session/otp/<otp>, the member completes the existing confirmation form, and only then is the originally requested destination loaded. The confirmation step is never bypassed: it is what sets the session cookie. An existing session is reused rather than spending an OTP: a live Discourse _t cookie means the destination loads directly. The cookie package is required lazily so importing this module does not pull a native dependency into every suite that reaches Discourse.js. The WebView policy relaxation is scoped to a bootstrap the app itself started. Authorization requires a pending destination, and the window closes the moment that destination loads, so this is not a standing "any internal page opens" rule. The original guard is untouched for everything else. Safety holds throughout. The decrypted OTP must match the route's hex constraint before it is interpolated into a path. Off-origin destinations are refused before an OTP is minted. Non-staff /admin, malformed payloads, unknown types and unauthenticated callers remain denied. Any failure or cancellation ends in a bounded explicit state rather than a blank WebView. Native Topic and MemberProfile routing, notification read-marking and the private member-photo credential boundary are unchanged. Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssions (#22) Physically certified on iPhone against OTA SHA ab581ac / group 87d6b1d1-a918-478c-afc9-0cbb27ce6292. Autobiographer PASS and Basic PASS: native Badge Earned screen, correct badge name, no WebView, no OTP or login, no hung state, no external browser, close/back works. Topic/Reply/Mention physical regression was not exercised because no notification fixture existed; automated regression coverage is accepted for that path. Shipped SHA preserved by the immutable provenance tag ota-87d6b1d1-ab581ac0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reconciles the pushed Mac lineage through 0565d50. Native credential retirement now requires the exact canonical server 401 tuple; ordinary 401/403, policy/onboarding, 429, offline, and 5xx remain non-retiring.