Skip to content

Retire credentials only on canonical server signal - #4

Open
Aalv3 wants to merge 20 commits into
reconcile/ios-native-freeze-systemic-admission-20260828from
fix/auth-failure-classification-20260830
Open

Aalv3 wants to merge 20 commits into
reconcile/ios-native-freeze-systemic-admission-20260828from
fix/auth-failure-classification-20260830

Conversation

@Aalv3

@Aalv3 Aalv3 commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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.

Aalv3 and others added 20 commits August 30, 2026 16:36
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
* 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.
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