DID:PLC resolution and operation submission.
DID:PLC is Bluesky's novel DID method with key rotation and recovery support.
DIDs are resolved via the PLC directory at https://plc.directory.
Reads live here; the write side is split across three modules:
Exosphere.ATProto.Identity.DID.PLC.Operation— build and validate operationsExosphere.ATProto.Identity.DID.PLC.Signer— sign, verify, derive the DIDExosphere.ATProto.Identity.DID.PLC.AuditLog— validate a whole operation log
Examples
iex> Exosphere.ATProto.Identity.DID.PLC.resolve("did:plc:z72i7hdynmk6r22z27h6tvur")
{:ok, %Document{...}}Creating an identity — the DID falls out of the signed genesis operation rather than being allocated:
{:ok, op} = Operation.new(rotation_keys: [key], also_known_as: ["at://alice.example.com"])
{:ok, signed} = Signer.sign(op, private_key, :secp256k1)
{:ok, did} = Signer.derive_did(signed)
{:ok, ^did} = PLC.submit(did, signed)
Summary
Functions
Get the audit log for a did:plc.
Get the current PLC data (internal representation) for a did:plc.
The CID of the current head of a DID's operation log.
Resolve a did:plc to its DID Document.
Submit a signed operation to the directory (POST /:did).
Fetch and validate a DID's full audit log.
Types
@type resolve_opts() :: [ timeout: pos_integer(), plc_directory: String.t(), http_client: module() ]
@type submit_opts() :: [ timeout: pos_integer(), plc_directory: String.t(), http_client: module(), max_attempts: pos_integer(), backoff_ms: pos_integer() ]
Functions
@spec get_audit_log(String.t(), resolve_opts()) :: {:ok, [map()]} | {:error, term()}
Get the audit log for a did:plc.
Returns the full history of operations for the DID.
@spec get_data(String.t(), resolve_opts()) :: {:ok, map()} | {:error, term()}
Get the current PLC data (internal representation) for a did:plc.
This returns the PLC-specific data format, not the DID Document.
@spec head(String.t(), resolve_opts()) :: {:ok, String.t()} | {:error, term()}
The CID of the current head of a DID's operation log.
This is what a new operation's prev must point at. Fetch it rather than
reusing a locally cached value: an operation chained from a stale head is
a fork, not an update, and the directory will judge it as one.
@spec resolve(String.t(), resolve_opts()) :: {:ok, Exosphere.ATProto.Identity.Document.t()} | {:error, term()}
Resolve a did:plc to its DID Document.
Options
:timeout- HTTP request timeout in milliseconds (default: 10_000):plc_directory- PLC directory URL (default: "https://plc.directory"):http_client- HTTP client module implementingHTTP.Behaviour(default:Exosphere.ATProto.HTTP; useful for testing)
@spec submit( String.t(), Exosphere.ATProto.Identity.DID.PLC.Operation.t(), submit_opts() ) :: {:ok, String.t()} | {:error, term()}
Submit a signed operation to the directory (POST /:did).
Retries on transport failures and 5xx responses with linear backoff. A 4xx is not retried — the directory has judged the operation invalid, and resending it unchanged cannot help. The exception is 429: it comes from infrastructure rate limiting (the directory's own limits answer 400), not a judgment on the operation, so it retries like a 5xx.
Note that the directory rate-limits per DID — 10 operations an hour, 30 a day, 100 a week — and retries count against those limits.
Submission is idempotent at the directory: resubmitting an operation the directory already holds is accepted rather than duplicated, so a retry after an ambiguous timeout is safe.
Options
:max_attempts- total attempts including the first (default: 3):backoff_ms- base backoff, multiplied by attempt number (default: 500):timeout,:plc_directory,:http_client- asresolve/2
@spec verify_audit_log(String.t(), resolve_opts()) :: :ok | {:error, term()}
Fetch and validate a DID's full audit log.
Verifies every signature, the chain, nullification and the 72-hour recovery window, and cross-checks the computed nullification against the flags the directory reported.