Troubleshooting

This page collects the failure modes observed while operating Boris's standard-site family against bsky.social / bsky.network PDS instances, and how to diagnose them. The full run history is in Test Runs; the happy-path procedures are in Standard.site Test Procedures and Standard.site — the Boris/ATProto loop.

The InvalidNonce finding (OAuth + DPoP writes)

Symptom: boris standard-site smoke (or any OAuth write) fails with InvalidNonce (exit 3), reproducibly, on a freshly signed-in session, against bsky.network PDS instances.

What happens: standard-site login --did <did> succeeds — the read-side discovery and token exchange complete — but the record writes are rejected at the DPoP nonce layer. The OAuth access token is bound to the DPoP key, and each request must carry a proof with a server-issued nonce; the PDS rejects the proof. The refresh path compounds it: an expired access token (1-hour lifetime) triggers a DPoP refresh that fails at the network layer rather than surfacing a clean "session expired" state.

What it means: on bsky.social / bsky.network, the OAuth + DPoP write path is non-parity — login works but writes are rejected. This matches the upstream Boris note that bsky.social does not reliably grant the Standard.site write scope via the OAuth/DPoP path.

Workaround: use the app-password path for live writes (see below). The recorded passing live smoke against bsky.social used the opt-in app-password (Bearer) path.

If OAuth writes are a hard requirement: they need a self-hosted PDS that implements and grants the granular collection scopes (repo:site.standard.document, repo:site.standard.publication). The OAuth path is not the supported live target on bsky.social today.

App-password vs OAuth — the tradeoff

OAuth + DPoP App-password
Scope granular (site.standard.*) broad account write
Token binding DPoP key binding Bearer
Works on bsky.social ✗ (writes fail with InvalidNonce) ✓
Credential entry browser OAuth stdin only
Least privilege yes no — use a dedicated identity
CI-friendly no (needs interactive grant) yes, via a repo secret

Decision rule: if you're publishing to bsky.social, use app-password. If you control a compliant PDS and need granular scopes, use OAuth.

Diagnosing a failed publish

When boris standard-site publish (or smoke) fails, walk this checklist in order:

1. Did it fail offline or live?

2. Check the session

boris standard-site sessions

3. Check the plan matches the profile (fail-closed guard)

boris standard-site plan --profile standard-site.json --out /tmp/plan.json
boris standard-site publish --profile standard-site.json --plan /tmp/plan.json

Publishing with a committed --plan is fail-closed on drift: if the profile's DID, origin, or record projection diverges from the committed plan, publish refuses. If publish dies on drift, regenerate the plan, review the diff, and commit the new plan — then publish against it.

4. Check the identity + origin in the profile

grep -E '"(did|base_url|origin)"' standard-site.json

5. Check verify against the built tree

boris standard-site verify --profile standard-site.json --dist dist --out verify.json

6. Live-side failures

7. The evidence trail

Every command writes an artifact:

Command Artifact Tells you
plan evidence/plan.json the projection (documents, exclusions, well-known path)
records evidence/records.json the canonical payloads
verify evidence/verify.json offline gate status
publish evidence/publish*.json per-record outcome (created/updated/failed) + CIDs
smoke evidence/live-smoke.json per-phase result (discovery → cleanup)

Check the artifact for the failing step first — the diagnosis is usually written down in it.

Still stuck?