Skip to content

fix(atproto): stop a failed enable from trapping crossposting on - #334

Merged
hm21 merged 8 commits into
mainfrom
fix/atproto-enable-rollback
Aug 9, 2026
Merged

fix(atproto): stop a failed enable from trapping crossposting on#334
hm21 merged 8 commits into
mainfrom
fix/atproto-enable-rollback

Conversation

@hm21

@hm21 hm21 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

  • enable_user_atproto_with_trigger / reenable_user_atproto_with_trigger now roll the opt-in back to enabled=false when the control-plane trigger fails, instead of leaving enabled=true, state='failed'.
  • disable_user_atproto_with_trigger completes the local disable when the trigger fails and the account has no DID. A provisioned account (DID present) still returns the error, unchanged.
  • The lifecycle writes now carry the existing atproto_did forward instead of nulling it, so the DID that gates the disable above survives a failed enable.
  • The web settings card keys its failure phase on the state alone, since a rolled-back enable no longer reports enabled=true.
  • Tests: the existing enable-failure test now pins the rollback; new tests cover a provisioned enable failure (DID kept, disable still refused), the reenable rollback, and disabling a stuck unprovisioned account both directly and through set_user_atproto_crosspost.

Motivation

enable_user_atproto persists enabled=true, state='pending' before it calls the control plane, and on trigger failure the old code wrote enabled=true, state='failed'. That leaves an opt-in that exists nowhere except that row — no gateway record, no repo, no DID — while /api/user/atproto/status reports crossposting as on.

The second half is what makes it a trap rather than a cosmetic wrong flag: the disable path calls the control plane first and only writes disabled after it succeeds. So while the control plane is unavailable, the same outage that refused the enable also refuses the disable, and the account stays switched on with no way for the user to switch it off.

This is reproducible in production right now. In the mobile client (divinevideo/divine-mobile, Settings → Bluesky Publishing) the toggle reads as on with status "Account provisioning failed", and turning it off returns the scoped 503 from #281 ("Bluesky publishing is temporarily unavailable"). Verified on a real Samsung Galaxy against production login.divine.video; the handle it advertises (<username>.divine.video) resolves nowhere — no /.well-known/atproto-did, no _atproto TXT record, Profile not found from the public Bluesky API.

Design notes for review:

  • Why roll back rather than keep the intent. If the trigger never reached the control plane, the intent exists only here and is safe to drop; the user can retry. If it did land before the response failed, provisioning reports back through internal_sync_atproto and the state converges there — so the rollback is self-healing in that race, whereas today's stuck flag is not.
  • Why the disable escape hatch is gated on the DID. Without a DID there is no repo to publish from, so honouring "off" locally cannot produce a false "off". With one, refusing is correct: a live account could keep publishing while the user believes it is off. That boundary is the existing disable_trigger_failure_preserves_existing_state test, which stays green.
  • Why the enable path stopped nulling the DID. set_atproto_state writes atproto_did unconditionally, and every lifecycle write passed None — so the enable path erased the exact signal the gate above depends on. An enable against an already-provisioned account, or a control-plane sync landing mid-write, left did=NULL with a live repo behind it, and the next disable would then take the unprovisioned path. Carrying the DID through is safe: a stale DID authorizes nothing on its own (ready_atproto_identity also requires state='ready'), and provisioning overwrites it through the internal sync.
  • Known limit of the escape hatch. It is a local release, not a remote one. If the original enable did reach the control plane, a late internal_sync_atproto can report enabled=true and switch the account back on after the user turned it off — the mirror image of the convergence that makes the enable rollback safe. Recovering from that needs the gateway to be reachable again, which is the same condition that makes the normal disable work.
  • Out of scope. Whatever is making DIVINE_SKY_ATPROTO_CONTROL_PLANE_URL (divine-handle-gateway) fail in production is an operational issue this PR does not touch — it only stops that outage from leaving accounts in an unrecoverable state. A durable retry queue for remote disables is likewise out of scope; the gateway remains the source of truth for the remote link.

Related Issue

  • Closes #none

Testing

  • cargo test --workspace --verbosenot run locally: this machine has neither Docker nor a local Postgres, and the ATProto suites need common::setup_test_db. Please let CI confirm the five touched tests in api/tests/atproto_http_test.rs.
  • e2e/tests/bluesky-settings.spec.tsnot run locally (needs the Playwright stack); the fixture change is mock-only.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated — clean
  • cargo fmt --all -- --check — clean
  • cargo test -p keycast_api --lib — 190 passed
  • cargo test -p keycast_api --test atproto_http_test --no-run — the new tests compile
  • CI note: run 30899938228 first failed in test_suggest_users_for_admin_uses_the_email_trigram_index (keycast_core, untouched here). Re-running the identical commit passed; the plan assertion depends on ambient row counts and is tracked in test(core): email-trigram plan assertion depends on ambient row counts #335. Every test in this PR passed in both runs.
  • Manual verification: reproduced the stuck state on a production account from the mobile client (see Motivation). The fix itself is not manually verified — it needs a deploy.

Visuals

  • No visual change to the mobile client. In the keycast web card, an account whose enable failed now renders the existing "Failed" state (badge, last-error text, retry button) instead of the plain enable card — no new UI.
  • Visuals and text avoid sensitive external brand or partner names unless explicitly approved

enable_user_atproto writes the opt-in before the control plane is called,
and a failed trigger only downgraded the state to 'failed' — the enabled
flag stayed true. The account then reads as publishing with no repo
behind it, and there is no way back out: disabling calls the control
plane first, so the same outage that refused the enable also refuses the
disable.

Roll the flag back when the trigger fails, and let a disable finish
locally when there is no DID to publish from. A provisioned account still
fails loudly, because reporting "off" while a live repo can keep posting
would be the worse lie.
@hm21 hm21 self-assigned this Aug 4, 2026
hm21 added 2 commits August 4, 2026 12:15
The disable escape hatch gates on `atproto_did` being null to decide that
an account has no repo to publish from. Every lifecycle write passed
`None` for that column, and `set_atproto_state` writes it
unconditionally — so the enable path itself erased the signal the gate
depends on. An enable on an already-provisioned account (or a control-
plane sync landing mid-write) left `did = NULL` with a live repo behind
it, and the next disable would then take the unprovisioned path and
report "off" while the gateway kept publishing.

Carry the current DID through `enable_user_atproto`,
`reenable_user_atproto`, and the rollback instead. A stale DID cannot
authorize anything on its own — `ready_atproto_identity` also requires
`state = 'ready'` — and provisioning overwrites it through the internal
sync once the control plane reports back.

Also drop the redundant existence query in the disable path: the state
read that the DID gate needs already yields the same `UserNotFound`.

Tests: a provisioned enable failure keeps its DID and still refuses the
disable; the reenable rollback (previously untested) is pinned.
The settings card keyed its `failed` phase on `enabled && state ===
"failed"`, a shape the API no longer emits now that a failed enable
rolls the opt-in back. Such an account fell through to
`ready_to_enable`, which still offers the retry button but drops the
"last provisioning attempt failed" explanation and the error text.

Key the phase on the state alone, and move the e2e fixture to the shape
the API now returns.
@hm21
hm21 marked this pull request as ready for review August 6, 2026 11:50
@hm21
hm21 requested review from a team, dcadenas and rabble and removed request for a team August 6, 2026 11:50

@dcadenas dcadenas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ The rollback and the DID-gated local release are the right shape, and the interleaving tests pin them properly.

Three gaps remained around the pre-trigger write. I pushed fixes rather than handing them back; detail is inline.

Changes I pushed

  • The opt-in write no longer reads atproto_did and writes it back — it omits the column, so there is no window for a provisioning sync to be overwritten.
  • A confirmed ready account is now excluded from that write, so a redundant enable during an outage cannot report a publishing account as switched off.
  • A rollback that lands now revokes ATProto OAuth sessions, matching the invariant the local-release test states.

The ready exclusion is the widest of the three: enabling an already-ready account now returns current status without calling the control plane. pending stays eligible, so a stuck opt-in can still be retried.

Checks

  • cargo fmt --all -- --check and cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated
  • cargo test --workspace, plus the integration-tests suites for core, api and signer
  • cargo test -p keycast_api --test atproto_http_test: 35 passed
  • cargo build --release --workspace
  • CI is green on the pushed head.

The two behaviour changes are pinned by tests that fail when the fix is removed — I removed each one and confirmed only the matching test failed. The two DID tests are regression guards, not race reproductions; the window they close has no seam to interleave into, and the fix removes it structurally rather than detecting it.

enable_failure_keeps_the_did_so_disable_still_refuses now seeds pending with a DID instead of ready, because the ready exclusion short-circuits before the trigger runs. Its intent and assertions are unchanged.

Merge stays with you.

Comment thread core/src/repositories/user.rs
Comment thread core/src/repositories/user.rs
Comment thread api/src/api/http/atproto.rs
Folding the username resolution into reenable_user_atproto_with_trigger
left reenable_user_atproto with no callers in src or tests, and as a
verbatim copy of the first half of the function that replaced it. It is
pub, so nothing warned about it.

The two doc links that pointed at it now name what actually does the
work: begin_atproto_opt_in for the write, and the trigger wrapper for the
re-enable path.
@hm21
hm21 merged commit 29a6963 into main Aug 9, 2026
6 checks passed
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.

2 participants