Build a Gleam PDS, one lesson at a time #
Working plan — October 3, 2026.
This document is also the complete handoff for the model teaching and implementing the project. It does not require access to the planning conversation. Read the overview first, then the execution manual. The manual makes the project choices concrete and divides the milestones into executable lesson cards. Its more specific instructions refine the overview.
Navigation for execution:
- Teaching and resumption instructions
- Fixed decisions and release gates
- Progress templates and exact source routes
- File/module layout
- Configuration and database contract
- Algorithms and important protocol contracts
- Endpoint inventory
- Cards M0–M6: foundation and repositories
- Cards M7–M9: identity, writes, sync
- Cards M10: OAuth and clients
- Cards M11–M12: deployment and recovery
- Test harness and evidence
- Troubleshooting
- Prompt for the next model
What we're working toward #
Build an AT Protocol Personal Data Server in Gleam that hosts one disposable test account and eventually works with Bluesky. Learn Gleam and PDS implementation together, using small features we can run, inspect, and explain.
You already know how to program in another language. We'll concentrate on Gleam's approach to types, immutable data, pattern matching, explicit errors, and concurrency, introducing protocol concepts as we need them.
We'll recognize three distinct achievements:
- Local learning server: a Gleam program that handles HTTP and persists records. This gives us something useful to experiment with, but isn't yet an interoperable PDS.
- Interoperable repository server: other implementations can verify our signed repository, fetch its records and blobs, and follow its updates.
- First Bluesky-compatible version: a fresh test account can authenticate with our selected client, publish a text post and an image, and have its public data indexed by the intended network. We can restart and restore the server without losing the account or its data.
This first version has one provisioned account and closed registration. Hosting other people's accounts, extensive administration, and high availability come later. We'll keep account ownership explicit in storage and APIs so a second account doesn't require redesigning everything.
What a PDS does #
A PDS holds an account's public records and media, signs repository updates, manages account access and identity, and serves data to clients and other servers. A relay collects updates from PDSs. An AppView indexes those updates to provide application features such as feeds and search. These responsibilities are separate in the AT Protocol architecture.
For example, creating a Bluesky post will eventually follow this path:
- The client sends an authenticated record write to our PDS.
- Our PDS validates it, creates a signed repository commit, and persists the write and its event.
- A relay consumes the event and makes the update available downstream.
- The Bluesky AppView indexes it so clients can display the post.
We'll implement the PDS and use existing relay and AppView services for integration. Building a feed or search engine is a separate project.
Repository records are public protocol data. Credentials, sessions, and private keys belong in separate private storage.
How we'll work together #
Each session should have one observable outcome and usually fit into about 45–90 minutes. A milestone can take several sessions; repository encoding, synchronization, and OAuth will need especially small subdivisions. We'll adjust the pace based on what you find interesting or confusing.
Our session loop:
- Explain: connect one protocol idea with the Gleam concept needed to implement it. Introduce terminology with an example.
- Predict: look at a small function, request, or data structure and discuss what should happen.
- Build: you write or modify a manageable piece. I explain the surrounding code, help with unfamiliar syntax, and handle setup or repetitive plumbing when useful.
- Observe: run the program, send a request, inspect bytes, or exercise a relevant test. Include an invalid input or failure case when that teaches the feature's contract.
- Reflect: explain why the result is correct and how it fits into the PDS. Discuss any remaining questions.
- Record: update our learning log with what works, concepts covered, decisions, and the exact next exercise.
The default is coached implementation: small exercises and explanations before full solutions. When you want an example or want me to implement a piece, I'll do that and walk through it. We won't introduce several unfamiliar subsystems in one change.
At the start of later sessions, we'll read this plan and the learning log. At the end, we'll leave a working checkpoint whenever practical. A compiler error or a protocol mismatch can become the lesson for that session.
Initial technical direction #
These are proposed defaults. We'll validate each dependency with a small experiment before building a major feature on it, and record alternatives when evidence calls for a change.
| Area | Starting choice | Why it fits this project |
|---|---|---|
| Runtime | Gleam targeting Erlang on the BEAM virtual machine | Gives us access to the Erlang ecosystem and a path to supervised processes. |
| HTTP | Mist directly, with gleam_http request/response types |
One transport boundary handles ordinary HTTP and binary WebSockets. |
| JSON | gleam_json and typed decoders |
Makes the boundary between untrusted input and our own types explicit. |
| Persistence | SQLite, initially through sqlight |
Keeps a single-account server straightforward to run and back up. |
| Media | Files in a dedicated data directory, with metadata in SQLite | Keeps larger blob bytes separate from the record repository. |
| Cryptography | Established Erlang cryptographic libraries behind small typed Gleam interfaces | Lets us learn protocol signing and key handling while reusing cryptographic primitives. |
| Verification | gleeunit, official interop fixtures, and an independent AT Protocol implementation |
Checks language-level behavior and actual protocol compatibility. |
The Gleam project guide covers the build and test workflow. Mist and sqlight document the proposed server and database libraries. We'll use Gleam OTP when we reach process ownership and supervision. Wisp was considered; defer it to avoid having two request-body abstractions while learning HTTP and WebSockets.
Two early experiments matter: whether our chosen HTTP integration can serve both ordinary requests and binary WebSocket frames, and whether our crypto bridge produces the key and signature formats AT Protocol expects.
We'll aim for a pure Gleam core for validation, encoding, and repository transformations, with HTTP, database, filesystem, clock, and cryptography operations at its boundaries. We'll introduce modules as their responsibilities become clear rather than scaffold a large architecture at the start.
Likely module groups are syntax, data_model, repo, identity, auth, storage, xrpc, and sync. They are a direction, not a prerequisite for lesson one.
Milestones #
Work through these in order. Each has a concrete completion check; completing a local demonstration doesn't automatically establish network compatibility.
0. Set up the project and trace one post #
Learn: modules, imports, functions, type annotations, and the compile/run/test cycle. Understand the difference between a record, a repository, an identity, and an application view.
Build: a minimal Gleam package in this directory, preserving this plan. Add a short README and a learning log. Run the generated program and test suite, then change a function and deliberately inspect a compiler error.
Complete when: you can run and change the program yourself and describe where a post lives and how another client eventually sees it.
Use the introductory sections of the Gleam language tour alongside the exercise. No need to finish the entire tour before writing project code.
1. Model records and identifiers #
Learn: custom types, Result, Option, pattern matching, lists, dictionaries, and immutability. Introduce opaque types after we've seen why validation needs a boundary.
Build: a small record model and a parser for a repository path such as app.bsky.feed.post/example. Separate the collection name from the record key. Gradually introduce validated identifier types for collection names, DIDs, handles, and AT URIs as their features arrive.
Complete when: valid examples parse, invalid examples return useful typed errors, and you can explain how the compiler helps callers handle both outcomes. Follow the actual syntax rules for each identifier rather than applying one generic string validator.
2. Serve our first HTTP and XRPC responses #
Learn: HTTP requests and responses as values, JSON encoding and decoding, function composition, and Gleam's use expression where it makes a handler clearer.
Build: a localhost HTTP server with a health endpoint, structured errors, and an initial com.atproto.server.describeServer response using explicit development configuration. Exercise Mist's binary WebSocket support in a separate small experiment.
Complete when: requests return the expected status, headers, and JSON; malformed inputs fail predictably; and the HTTP/WebSocket integration experiment succeeds.
XRPC is the protocol's HTTP API convention, and Lexicons describe endpoint and record contracts. We'll read the relevant portions of the XRPC specification and Lexicon specification with concrete requests in front of us.
3. Persist a local record store #
Learn: dependency boundaries, decoding database rows, explicit I/O errors, SQL constraints, and transactions.
Build: create/read/list/update/delete operations behind a storage module. Start with one local test owner and collection. Use parameterized SQL, migrations, deterministic listing, and a pagination approach we can explain.
Keep this exercise local, with any temporary write routes clearly marked as development routes. We'll connect protocol write endpoints only after signing and authorization exist.
Complete when: records survive a restart, updates and deletes behave correctly, pagination doesn't silently skip records, and a failed transaction leaves the old state intact.
This is the storage exercise. The signed repository representation arrives in the next milestones.
4. Encode protocol data and compute content identifiers #
Learn: recursive data types, byte arrays, binary pattern matching, and the distinction between a value and its serialized bytes.
Build: a generic AT Protocol data value representation, JSON conversions, deterministic CBOR encoding, and content identifier parsing and creation. Preserve the difference between absent fields and explicit nulls. Validate byte and link representations and bound untrusted input size and depth.
The current data-model specification calls its normalized CBOR format DRISL, the successor to DAG-CBOR. Older material and library names often say DAG-CBOR. We'll test the required normalization rules instead of assuming any CBOR encoder is sufficient.
Complete when: official data-model fixtures produce the expected bytes and identifiers, malformed values are rejected, and equivalent values encode consistently. We can show how changing a record changes its content identifier.
Choose reusable encoding libraries where they meet the constraints; implement the missing protocol-specific pieces in Gleam where needed.
5. Construct the Merkle Search Tree #
Learn: recursion over tree structures, immutable updates, and reasoning from invariants.
Build: the AT Protocol Merkle Search Tree (MST), which maps record paths to content identifiers. Begin with an empty tree and a tiny sorted set of records, then add lookup, insertion, replacement, and deletion. A complete rebuild can be our first correct implementation; incremental performance can follow.
Complete when: the same record set produces the same root regardless of insertion order, updates and deletes match expected roots, and official MST fixtures pass. Include awkward tree shapes and deletion cases before proceeding.
The repository specification defines the structure. This milestone is likely to span several lessons.
6. Sign commits and export a repository #
Learn: typed interfaces to external Erlang code, key ownership, signing versus hashing, and composing independently tested components.
Build: a crypto compatibility experiment, durable signing-key storage, version-3 repository commits, monotonically increasing revisions, a block store, and CAR archive export/import for local verification.
Start generating one supported key type; verify both supported curve types before the interoperability milestone. Keep signing keys distinct from identity recovery/rotation keys. Handle required signature normalization and encoding using tested libraries and adapters, following the cryptography specification.
Complete when: an independent implementation can read and verify our exported repository, our reader can verify a known external repository, and corrupt bytes or a wrong signing key fail verification. Compare data bytes and tree roots deterministically; verify signatures rather than assuming signatures must be byte-identical.
7. Establish identity and account access #
Learn: configuration, interacting with remote services through typed results, token lifecycles, and separating identity from login credentials.
Build: provision one account, publish or resolve its DID document, link its handle in both directions, and implement password/session access for the clients that require it. Candidate endpoints include createSession, getSession, refreshSession, and deleteSession under com.atproto.server.
Use identity fixtures locally. For the public test account, choose the DID method after checking our selected client's requirements; did:plc is the proposed default. Identity resolution should support the protocol's required DID methods. Teach domain/DNS setup only when we reach the public integration step.
Use established password hashing, secure random tokens, expiry checks, revocation, and explicit account ownership. Password sessions and OAuth are separate paths; OAuth has its own milestone below.
Complete when: the account's identity resolves to its actual PDS and signing key in the integration environment, valid sessions work, and wrong credentials, expired/revoked tokens, and unauthorized writes fail. The account and keys survive a restart.
Read the DID, handle, and account specifications as the corresponding features are built.
8. Connect real record writes and media #
Learn: application services, explicit authorization, transaction boundaries, and making multi-step changes atomic.
Build: connect record operations to validation, the MST, signing, and persistence. Implement the needed com.atproto.repo operations: getRecord, listRecords, describeRepo, createRecord, putRecord, deleteRecord, applyWrites, and uploadBlob.
Enforce account ownership, preconditions such as expected record or commit versions, and all-or-nothing batch writes. Keep a consistent record index, repository head, block set, and durable event entry. Start with a text-post schema, then an image reference. Specify how unknown Lexicons and the request's validation settings behave; preserve supported generic protocol data without pretending every application schema is known.
Store blob files atomically and track upload ownership, size, type, references, and cleanup separately. CAR exports don't replace blob backups. Use the blob specification for these lifecycle rules.
Complete when: an independent client can create, read, update, and delete records; exports verify after each write; conflicting writes fail; and interrupted writes don't leave a partially published repository. Uploaded and referenced images can be retrieved correctly.
9. Publish a durable synchronization stream #
Learn: processes, typed messages, supervision, backpressure, and the difference between in-memory notifications and persistent events.
Build: the sync endpoints needed by the selected relay, including repository export, account enumeration/status, blob retrieval, and com.atproto.sync.subscribeRepos. Serve binary protocol events with persistent sequence numbers and resumable cursors.
Serialize writes through a clear owner and enforce consistency in the database. Persist each event with its repository update, then notify subscribers after commit. Define retention, recovery from an expired cursor, and behavior for slow subscribers. Add commit, identity, account, and sync events as required by supported account operations.
Follow the current synchronization specification, including the proof/context blocks needed to validate repository differences. Don't treat a JSON broadcast as the protocol firehose.
Complete when: an independent consumer verifies a create/update/delete stream, reconnects from a cursor, and reconstructs the same repository head. Restart and crash exercises demonstrate that committed writes have durable events and rolled-back writes have none.
10. Support OAuth and AppView requests #
Treat these as two smaller milestones with independent completion checks.
OAuth — learn and build: discovery metadata, a login/consent interface, authorization requests, code exchange, scopes/permissions, refresh, and protected-resource validation. Our single-account server will initially act as both resource server and authorization server. Reuse established OAuth/JOSE components where suitable, adding the AT Protocol profile through tested Gleam interfaces.
AT Protocol OAuth includes PKCE, pushed authorization requests, and DPoP with server-provided nonces. These are part of the OAuth profile, so publishing metadata alone doesn't complete support.
OAuth complete when: an independent current OAuth client can authorize, read/write within its granted permissions, refresh, and revoke a session. Replayed proofs, incorrect request bindings, invalid redirects, and insufficient permissions are rejected. Bound external metadata fetches and prevent requests to unintended internal services.
AppView proxy — learn and build: route the selected client's application requests to its AppView, using DID/service resolution and correctly scoped service-authentication tokens. Check audiences, method restrictions, and allowed destinations against the current XRPC rules. Keep client credentials at the PDS boundary.
Proxy complete when: authenticated profile/feed requests reach the intended service, upstream errors remain understandable, and unrelated destinations or invalid service-auth tokens are rejected.
At the start of this milestone, capture which client/version we're targeting and its actual endpoint and login requirements. Keep any additional client-required features visible in the compatibility checklist.
11. Integrate a disposable account with Bluesky #
Learn: deployment configuration, HTTPS, DNS, process management, logs, and diagnosing compatibility from requests and events.
Build: a reproducible local integration setup using existing reference services, then a public test deployment with a dedicated hostname and test identity. Check the intended relay's current discovery/crawl procedure and the selected AppView's acceptance requirements before connecting.
Complete when: the selected Bluesky client signs in, publishes a text post and image, and retrieves its profile/feed; the AppView indexes the posts; updates and deletes reach independent consumers; and a restart preserves identity, data, and usable event cursors within our retention window.
These are external integration checks, not promises about indexing time. If an upstream service rejects or doesn't index the account, use request traces and consumer verification to separate our protocol behavior from upstream policy or availability. Record the exact limitation and keep improving independently verifiable behavior.
Use a new account throughout development. Decide separately when this server is reliable enough for an existing account.
12. Prove recovery and portability #
Learn: operational invariants, fault injection, restore procedures, and why identity recovery differs from restoring server files.
Build: coherent backups of the database, referenced blobs, keys, and configuration; a documented restore procedure; repository and blob import/export; and the account-status/identity changes needed for a migration exercise. Keep identity rotation/recovery material under intentional separate custody.
Add basic health reporting, useful logs, request/resource limits, and graceful shutdown around the paths we've implemented.
Complete when: restoring into a fresh instance preserves identity and repository verification, images are available, and a test migration preserves the account DID and records. Disabling an account stops the relevant access and publication paths. Document remaining gaps before considering hosting other users.
How we'll establish correctness #
Language tests alone don't establish PDS interoperability. We'll use three layers of evidence:
- Focused tests for protocol contracts and important failure cases: validation, bytes, tree roots, authorization, transactions, and recovery.
- Official implementation-neutral interop fixtures for syntax, encoding, cryptography, MSTs, Lexicons, and stream data where available. Pin the fixture revision we use. The specifications remain authoritative when a fixture disagrees with them.
- Cross-implementation checks against the reference AT Protocol implementation, plus observable behavior in the selected client and network.
At milestone 2, start an endpoint matrix: method, input/output schema, authorization requirements, implementation status, and verification evidence. Pin the canonical Lexicons to a reviewed revision. Expand the matrix for each integration target instead of assuming our initial endpoint list is exhaustive.
Record source revisions and material compatibility decisions in the learning log. Recheck current specifications when implementing a subsystem, especially authentication and synchronization; this plan is a route through the work, not a frozen substitute for the protocol.
Normal project checks will be gleam format --check, gleam check, and gleam test, with integration checks added as features arrive. We'll use tests that check meaningful behavior rather than adding tests for every simple helper.
Our first two sessions #
Session 1: a running program and the path of a post. Scaffold the project, read its entry point together, change the output, run the initial test runner, and inspect a type error. Draw or discuss the client → PDS → relay → AppView path. End with a program you can run and a short learning-log entry. The first meaningful protocol tests arrive with the parser in session 2.
Session 2: our first protocol-shaped function. Represent a record path with a custom type and return Result from a parser. Use one valid input and several invalid ones to introduce pattern matching and explicit errors. End with a working function whose contract you can explain.
After that, begin the HTTP milestone. We'll revisit this plan after the first working server and again after repository verification, when we'll have better evidence about scope and pace. Expect a substantial learning project extending over weeks or months rather than a weekend implementation; we can enjoy useful intermediate results along the way.
Current checkpoint #
- Planning is complete; implementation has not started.
- The project directory contained no code or Git repository when inspected.
- Available locally: Gleam 1.18.1, Erlang/OTP 29, and Rebar3. Tool availability is verified; project/dependency compatibility still needs the setup exercise.
- Learner background: comfortable programming in another language; new to Gleam and PDS implementation.
- Initial target: one disposable account that eventually works with Bluesky.
- Next exercise: milestone 0, session 1.
Execution manual for the model #
1. Instructions that must survive a model or session change #
The user wants to learn by building, not merely receive a finished codebase. The learner is an experienced programmer in an unspecified other language, and knows neither Gleam nor PDS implementation. Do not assume Erlang, functional programming, distributed identity, cryptography, or protocol encoding knowledge. Ask which language they know only if an analogy would help; that answer is not a prerequisite for starting.
Use the first incomplete lesson card below as the next task. Implement in pds/ only. Other projects in the parent directory are unrelated and may be production services. Read applicable repository instructions, preserve existing edits, and never borrow their credentials or make writes to their accounts.
At every session:
- Read this document and
LEARNING_LOG.md; inspect the relevant source and test files. - State the lesson's observable outcome in one sentence. Explain its protocol purpose and at most one or two new Gleam ideas.
- Give a small worked example, then one manageable learner exercise. Tell the learner exactly which file/function to change, what inputs to try, and what result to expect. Avoid vague instructions such as "implement authentication."
- If the learner is writing the exercise, leave a precise checkpoint and wait for their attempt. If they ask you to implement it, implement it and explain the important lines. A request to execute this plan starts the next lesson; it does not change the teaching objective into an unattended build of the entire server.
- Review the attempt. Explain compiler errors in terms of the actual types, and trace protocol mismatches from input to serialized bytes. Use a small hint before replacing the learner's solution unless they ask for the answer.
- Run the checks specified on the card and the applicable project checks. Record the actual commands and results. A check that was blocked is not a passing check.
- Update
LEARNING_LOG.md, the endpoint matrix when relevant, and the decisions register if a project choice changed. End with what now works, what the learner practiced, and the next card ID.
Keep the project runnable between cards. Temporary stubs must be labelled, restricted to test/development mode, and listed in the learning log. Do not make a stub look like a successful protocol operation: an unimplemented route must fail explicitly, not return invented account state or a placeholder CID.
Prefer pure functions with typed errors. Use case before introducing use or long pipelines. Explain use as callback syntax with its explicit-function equivalent. Use let assert only for startup assumptions and test setup whose failure is intentionally fatal; malformed HTTP, database, filesystem, and network input must return errors.
Avoid installing tools, changing frameworks, or rewriting working modules for stylistic reasons. A dependency/API mismatch calls for inspecting the installed version, not copying an example for a different version.
2. Fixed project choices and release boundaries #
These choices remove routine decisions from the executor:
- Package name:
pds; entry module:src/pds.gleam; runtime target: Erlang. Do not usegleam_pds: the compiler reserves thegleam_package prefix. - HTTP: Mist directly. Dispatch WebSocket upgrades before consuming the body as an ordinary HTTP request. Use
gleam_httpvalues inside ordinary handlers. Keep the Mist connection object at the transport boundary. - Storage: one SQLite database for private application state and repository blocks, plus a dedicated blob directory. One process owns the write connection. Database transactions, not actor memory, establish durability.
- Repository implementation: initially rebuild a complete MST from the record mapping on each write. Optimize only after byte/root compatibility checks pass. Initial supported repository format is version 3 and archive format is CARv1.
- Identity: deterministic fixtures in local unit/integration tests; a newly created
did:plcaccount for public integration unless the actual target client provides a concrete reason to choose differently. Implement bothdid:plcanddid:webresolution. - Own repository signing key: P-256 initially; verification supports P-256 and secp256k1 before interoperability is claimed. Keep the repository signing key, PLC recovery/rotation keys, server session secret, and OAuth signing key separate.
- Password storage: OTP's PBKDF2-HMAC-SHA256 as the initial established primitive, with a versioned hash record, random salt of at least 16 bytes, a 32-byte derived key, and initially 600,000 iterations. Benchmark on the deployment machine and recheck the OWASP guidance at implementation time. Limit concurrent password checks. Do not substitute SHA-256 alone or invent a password scheme.
- JWT/JWK operations: the Erlang
josepackage behind typed Gleam wrappers; native primitives through OTPcrypto/public_key. Probe required algorithms and raw/DER formats before use. JOSE documentation and OTP crypto documentation are the implementation references. - Registration: closed. An offline/local administration command provisions the one test account. Public
createAccount, email delivery, password recovery email, video, chat, moderation administration, and other users' accounts are outside the first release. Implement a missing feature if the chosen client needs it; record the scope addition explicitly. - Integration harness: Node/TypeScript may be used to run reference SDKs and compare outputs. It is a test tool, not the PDS runtime. Never replace repository logic with a Node subprocess in the server.
- Public client target: the official Bluesky web client, initially
bsky.app; also test a current independent AT Protocol OAuth client. Record the actual client release/commit or observation date, login method, relay, and AppView before testing compatibility. - Deployment target: a Linux host running a Gleam/Erlang release as an unprivileged service behind a TLS reverse proxy. A hostname, host/provider, and authority to publish must be supplied before public changes. Prepare configuration and verification locally while those details are absent.
Release boundaries:
| Gate | Required evidence | What it permits us to claim |
|---|---|---|
| L: after M3 | Working local requests; persisted CRUD; restart/rollback checks | Local learning server |
| R: after M6 and read endpoints in M8 | Official fixture results and independent repository import/signature verification | Repository-format interoperability |
| S: after M9 | Independent stream reconstruction, cursor replay, and crash checks | Synchronization interoperability |
| C: after M10–M11 | Actual selected-client login, writes, media, AppView reads, and observed indexing | Compatibility with that tested client/network |
| O: after M12 | Fresh-instance restore and test migration evidence; limitations recorded | First operational single-account release |
Passing one gate does not imply the next. M7 identity checks can use a local resolver fixture; real public resolution is rechecked at M11. M10 needs a test HTTPS origin for OAuth metadata; establishing a dedicated test origin is part of that lesson, before the full network deployment.
3. Progress, decisions, and sources #
Create these documents at M0.1, using the following structures. They will contain facts produced during execution, not guessed completion statuses.
LEARNING_LOG.md:
# Learning log
Current card: M0.1
Current release gate: none
Last working checkpoint: planning only
## Completed cards
| Card | Outcome | Learner concept | Evidence | Remaining limitation |
| --- | --- | --- | --- | --- |
## Current session
- Goal:
- Files involved:
- Learner exercise / attempt:
- Explanation or correction:
- Commands and actual outcomes:
- Open questions:
- Next exact action:
## Deferred work
| Item | Reason | Required before which gate? |
| --- | --- | --- |
docs/DECISIONS.md:
# Decisions
| ID | Decision | Reason/evidence | Affected cards | Revisit condition |
| --- | --- | --- | --- | --- |
Seed the decisions with the fixed choices in section 2. When a change is needed, record the evidence, exact replacement, and impact on future cards. Do not silently override the plan or drop an acceptance check.
docs/ENDPOINTS.md:
# Endpoint contracts and evidence
| NSID/path | Method | Canonical schema + revision | Auth | Input/output | Errors | Status | Evidence |
| --- | --- | --- | --- | --- | --- | --- | --- |
Statuses are not started, local stub, implemented, and independently verified. implemented means relevant tests pass; it does not mean a client/relay was tried. List optional fields, nullable fields, default values, bounds, and error names explicitly in each endpoint's supporting notes. Do not treat "nullable" as "optional."
docs/protocol/SOURCES.md must record source URL, checked date, checkout revision or package version, and which feature it governs. At M0.2 retrieve:
git clone --depth 1 https://github.com/bluesky-social/atproto.git .reference/atproto
git clone --depth 1 https://github.com/bluesky-social/atproto-interop-tests.git .reference/interop
git -C .reference/atproto rev-parse HEAD
git -C .reference/interop rev-parse HEAD
Run each command independently from the project root. Check whether a destination exists first; if it does, inspect its remote/revision instead of overwriting it. Add .reference/ to .gitignore. Do not initialize or push the project Git repository unless the current user request or established workflow authorizes it; local learning does not require a remote.
Copy the exact fixtures we use into test/fixtures/interop/ with their source revision and license. Copy the required canonical Lexicons and their transitive references into priv/lexicons/. Record checksums for copied files. Fixture adapters must report passed, failed, and skipped cases; a skipped case needed by our feature prevents its gate from passing.
For specifications, save a dated local reading copy in docs/protocol/reference/ where licensing permits. These copies are references, not runtime code. Each card identifies the necessary reading below. The executor must read those sections before implementing their rules and add an implementation checklist in docs/protocol/CONTRACTS.md. This is the planned way to obtain exact evolving wire contracts, not an unspecified research assignment.
Required source routes:
| Feature | Specification | Local reference checkout routes |
|---|---|---|
| Identifier grammar | /specs/nsid, /specs/record-key, /specs/tid, AT URI scheme, /specs/handle, /specs/did on atproto.com |
packages/syntax/; relevant syntax/ fixtures |
| Generic values and deterministic bytes | Data model, DRISL, CID | Find CBOR/data packages via rg --files packages; data-model/ fixtures |
| Tree and commits | Repository, Cryptography | packages/repo/src/mst/, packages/repo/src/repo.ts, packages/crypto/; mst/, crypto/ fixtures |
| Archives | CAR, repository archive section | Repo archive import/export implementations and tests |
| HTTP and sessions | XRPC, Accounts | lexicons/com/atproto/server/, packages/pds/src/account-manager/ |
| Record operations | Relevant JSON Lexicons | lexicons/com/atproto/repo/, packages/pds/src/api/com/atproto/repo/ |
| Media | Blobs | repo/uploadBlob.json, sync/getBlob.json, sync/listBlobs.json; application blob schemas |
| Live transfer | Event streams, Sync | lexicons/com/atproto/sync/, packages/repo/src/mst/; firehose/ fixtures |
| OAuth and scopes | OAuth, Permissions | OAuth provider/client packages and tests discovered in the pinned checkout |
| PLC operations | DID PLC method | Retrieve/pin this repository when M7 reaches operation creation |
Use rg --files and rg -n to find a moved file. Do not invent an export name or assume a package API from memory. If current specs, Lexicons, and an old fixture disagree, record the exact discrepancy and choose the current normative rule with a regression test; do not change a fixture's expected value merely to make our code pass. If the discrepancy affects interoperability and cannot be resolved, leave that gate incomplete and explain the precise issue.
If network access or dependency retrieval is unavailable, record the failing command/URL and continue independent reading/exercises where possible. Do not change correct application code to compensate for a DNS or permission failure. Ask for the missing environment capability only when it blocks the next required check.
4. File layout and module boundaries #
Create only the files needed by the current card. This is the eventual layout, not an instruction to generate every module immediately:
pds/
IMPLEMENTATION_PLAN.md
LEARNING_LOG.md
README.md
gleam.toml
manifest.toml
.gitignore
.env.example
src/pds.gleam # start supervision and listener
src/pds/config.gleam # validated config, no secret logging
src/pds/syntax/*.gleam # opaque identifiers and path parsing
src/pds/data_model.gleam # generic protocol values
src/pds/encoding/*.gleam # CBOR, CID, base encodings, varints, CAR
src/pds/repo/*.gleam # MST, commits, write plan, diff proofs
src/pds/storage/*.gleam # SQLite owner, migrations, queries
src/pds/identity/*.gleam # DID/handle resolution and PLC adapter
src/pds/auth/*.gleam # password sessions, OAuth, service auth
src/pds/http/*.gleam # transport, decoders, XRPC error mapping
src/pds/api/*.gleam # thin endpoint handlers
src/pds/sync/*.gleam # event construction and subscription
src/pds/blob/*.gleam # upload and blob lifecycle
src/pds/admin.gleam # local provisioning/backup operations
src/pds_ffi.erl # small typed adapters; split if needed
priv/migrations/ # numbered immutable SQL migrations
priv/lexicons/ # pinned schema registry
test/pds_test.gleam # gleeunit runner
test/pds/*_test.gleam
test/fixtures/
integration/ # independent SDK harness + lockfile
docs/ # decisions, contracts, endpoints, runbooks
deploy/ # release/service/reverse-proxy templates
data/ # ignored, private runtime data
.reference/ # ignored reference source checkouts
Rules for boundaries:
- Syntax functions accept strings and return validated opaque types or typed errors. Formatting functions expose canonical text. There is no public unchecked identifier constructor.
- The generic value model supports null, bool, signed integer, string, bytes, CID link, list, and string-keyed map. Application records stay generic values; a typed teaching example must not become a closed hard-coded set of all possible records.
- Encoding functions consume/produce byte arrays and have no database/network calls. A parser reports how many bytes it consumed when a surrounding format needs to parse another object.
- MST transformations consume a mapping or block reader and return a root plus encoded blocks. They never write SQLite directly.
- A repository write service accepts a validated authenticated principal, current state, operations, expected versions, and injected clock/key operations. It computes a candidate update, then commits it through the storage owner.
- HTTP handlers decode, authorize, call a service, and encode the result. They don't duplicate tree/signing/storage logic.
- The stream reads persisted events as its source of truth. Notifications wake consumers; they are not the durable event payload.
- Test clocks, deterministic IDs, fixture resolvers, and fault hooks are injected in tests. Production startup rejects fixture identity mode and placeholder keys.
- External Erlang adapters catch expected library exceptions and translate them into the actual Gleam
Resultrepresentation. Use generated Gleam types or verified runtime conventions; do not guess tagged tuple layouts. Test both success and failure across every FFI boundary.
For an optional nullable precondition, the intended type is a three-state value, for example:
pub type NullableField(a) {
NotProvided
ExplicitNull
Supplied(a)
}
This is a project type to implement, not a built-in Gleam feature. A JSON decoder must distinguish whether a key exists before decoding its value. For swapRecord, NotProvided adds no record-CID condition, ExplicitNull requires that the target record not exist, and Supplied(cid) requires its current CID to match. Test all three against both an absent and existing record. Keep the record-write result's URI, record CID, commit CID/revision, validation status, and event sequence distinct rather than passing an ambiguous tuple of strings.
Use dependency records containing functions for testable I/O initially. Do not build a general plugin framework or an abstraction layer for databases we don't use.
5. Configuration and storage contract #
Initial configuration names and defaults:
| Name | Development default | Production rule |
|---|---|---|
PDS_ENV |
development |
Explicit production; reject fixture/stub modes |
PDS_BIND |
127.0.0.1 |
Usually loopback behind the proxy |
PDS_PORT |
2583 |
Parse and bound to a valid port |
PDS_PUBLIC_URL |
http://127.0.0.1:2583 |
Required HTTPS origin, no credentials/query/fragment |
PDS_SERVICE_DID |
explicit local fixture | Required resolvable server DID |
PDS_DATA_DIR |
./data |
Explicit service-owned directory, separate from code |
PDS_DB_PATH |
./data/pds.sqlite |
Inside the approved data directory |
PDS_BLOB_DIR |
./data/blobs |
Inside the approved data directory |
PDS_KEYS_DIR |
./data/keys |
Private directory; secret files mode 0600 |
PDS_APPVIEW_DID |
absent | Explicit verified target DID |
PDS_APPVIEW_SERVICE |
absent | Explicit service fragment/name |
PDS_RELAY_URL |
absent | Explicit integration target; no automatic announcement |
PDS_SESSION_SECRET_FILE |
generated local file | Durable random secret; never autogenerated over an existing secret |
PDS_EVENT_RETENTION_HOURS |
72 |
Test replay/expiry; monitor storage |
PDS_MAX_JSON_BYTES |
4194304 |
Configurable bounded request body |
PDS_MAX_RECORD_CBOR_BYTES |
1000000 |
Enforce stricter schema/protocol bounds too |
PDS_MAX_VALUE_DEPTH |
64 |
Implementation resource limit, not a protocol constant |
PDS_MAX_BLOB_BYTES |
10485760 |
Upload policy; schema-specific references may be smaller |
Store optional integration targets as Option, not empty strings. Add a known OAuth-client allowlist and limits when M10 introduces them. Read configuration once at startup, reject inconsistent paths/origins, and print only non-secret effective settings. .env.example documents names and placeholders; the application must explicitly load .env or instruct the user to set environment variables. Do not imply a .env file is automatically loaded.
Use .gitignore entries for build/, .reference/, .env, data/, *.sqlite*, private keys, local credentials, and generated integration reports containing tokens. Keep manifest.toml, integration package lockfiles, migrations, example configuration, and fixture metadata in version control when Git is used.
M3 starts with a development-only table:
CREATE TABLE dev_records (
owner TEXT NOT NULL,
collection TEXT NOT NULL,
rkey TEXT NOT NULL,
value_json TEXT NOT NULL,
PRIMARY KEY (owner, collection, rkey)
);
Never expose this table as a conformant signed repository. At M6–M8 add the real storage tables with numbered migrations. The following is the schema contract; field names can be expanded, but the keys and invariants must remain:
| Table | Key and required content | Invariant |
|---|---|---|
schema_migrations |
migration number, checksum, applied time | Applied migrations aren't edited in place |
accounts |
DID PK; unique current handle; status; password-hash parameters; signing-key reference | Private account state is separate from records |
keys |
key ID PK; purpose; curve; public encoding; private file reference | A key's purpose is explicit; private bytes aren't served |
repo_heads |
DID PK; signed commit CID; MST root CID; revision | Exactly one current head per account |
repo_blocks |
(did, cid) PK; exact bytes; block kind |
CID matches bytes; existing CID can't be overwritten with different bytes |
records |
(did, collection, rkey) PK; current record CID |
A current record points to an existing owned block and appears in the current MST |
events |
global persistent integer seq PK; DID; event kind; revision if applicable; exact public body bytes; creation time |
A repository mutation and its event become durable together |
blobs |
(did, cid) PK; internal file reference; MIME; size; state; upload time |
Ownership and lifecycle are checked before publication |
record_blobs |
record key plus blob CID | Tracks current references; deleting one reference doesn't delete shared media |
password_sessions |
session ID; DID; refresh-token ID/hash; expiry; revocation | Refresh use/revocation is transactionally recorded |
private_preferences |
(did, application) PK; validated preference data |
Private application settings are not repository records or firehose events |
oauth_requests |
request URI hash; client ID; redirect; PKCE challenge; DPoP key; requested scopes; expiry | One short-lived pushed request binds all later auth steps |
oauth_codes |
code hash; approved request/session; expiry; used state | Authorization codes are consumed once |
oauth_sessions |
session ID; DID; client ID; DPoP key thumbprint; grants; token hashes/expiry; revocation | Token use is bound to client, account, proof key, and permission |
dpop_replays |
proof-key/session + nonce generation + JWT ID; expiry | Duplicate proofs fail; old entries expire safely |
Enable foreign keys on each SQLite connection; choose WAL and a bounded busy timeout; document the synchronous setting. The single owner keeps transactions from interleaving. Readers must observe a coherent committed head and mapping, using a read transaction for multi-query exports. Handle SQLITE_BUSY with bounded retries or an explicit failure, not an infinite loop.
Use parameterized SQL and bytewise/binary ordering for protocol paths. Pagination is keyset-based, scoped to DID, collection, and direction. Encode the last key and request scope in a versioned cursor; validate it on reuse. Document live-pagination behavior when records change between pages. Do not promise a historical snapshot unless we actually persist one.
Do not perform network requests while holding a write transaction. Prepare expensive pure computations outside the transaction if helpful, but recheck the head/version and preconditions inside the transaction before persisting them.
6. Protocol algorithms and failure-prone contracts #
This section defines the implementation approach and the checks that prevent common substitutions. Before coding each algorithm, complete its source-reading card and reconcile these notes with the pinned contracts. Constants copied below describe the checked protocol; resource-policy defaults are identified separately.
Identifiers and record paths #
Parsing a repository path is two tasks: split it into exactly two nonempty segments, then validate each segment with its own grammar. Start the lesson with structural parsing; finish it with a real NSID and record-key validator before using it in an API.
Record keys accept ASCII letters/digits and .-_:~, are 1–512 characters, and cannot be . or ... They are case-sensitive and are not all TIDs. In particular, colon is valid. A profile uses the literal key self; ordinary posts use TID keys. See the record-key rules and reference validator.
NSIDs require at least three segments and have different authority/name rules. Preserve the final name's case. Do not reuse a handle validator unchanged or lowercase the entire identifier. Read the NSID rules and the reference validator to implement every length/segment constraint.
Keep a record's identity as (did, collection, rkey). A record CID describes its content, not its path; identical record content can appear at different paths. A repository path is not a filesystem path and must never be used directly as a disk location.
Deterministic encoding, CIDs, and CAR #
Separate a generic protocol value from its JSON and CBOR representations. JSON ordering need not determine a CID. When reading a stored block, preserve its exact bytes; do not decode and re-encode it before checking the block's CID.
Encoding checklist:
- Strings are valid UTF-8; map keys are strings; use definite lengths and minimal integer/length encodings.
- For string-key maps, test the required ordering of UTF-8 byte length followed by byte comparison, including non-ASCII keys. Confirm it against the pinned DRISL/AT Protocol fixtures.
- Reject duplicate map keys and disallowed CBOR tags/simple values. AT Protocol values exclude floats even though the wider DRISL format describes them.
- Represent null and absent object fields separately. Do not convert missing fields into nulls during record round trips.
- CID links use tag 42 around bytes containing a leading zero byte plus the binary CID. Bytes and links have their own JSON wrappers, distinct from ordinary strings.
- A standalone block contains one complete value and no trailing bytes. The stream parser is deliberately a separate framing operation that reads two adjacent values.
For newly generated CIDs, use version 1, SHA-256 with a 32-byte digest, codec 0x71 for structured record/tree/commit bytes, and 0x55 for raw media. Binary encodings and lowercase unpadded base32 string encodings are distinct. Do not hash a hexadecimal string instead of the actual bytes. The CID specification provides the format.
Implement unsigned varint encode/decode once. Test 0, 1, 127, 128, 255, and a multi-byte value; reject truncation and excessive length. The CAR reader needs bounded length-delimited header/blocks, CID parsing, and byte-consumption accounting. Check every block hash, commit root, and reachable required repository block. Accept archive block ordering and duplication as allowed by the contract; reject inconsistent duplicate content. External record/media links are not missing repository-tree blocks. Follow the CAR format.
A simple, correct MST builder #
Use complete rebuilds first. The conceptual builder operates on sorted unique (path_bytes, record_cid) pairs with a layer for each key. The layer is the number of leading zero bits in SHA-256 of the path bytes, divided by two and rounded down.
Algorithm:
build_root(entries):
if entries is empty: encode the specified empty node
otherwise build_at_layer(entries, maximum key layer)
build_at_layer(entries, layer):
place all keys whose layer equals this layer into this node
partition the remaining keys into the sorted gaps around those keys
for each nonempty gap, build its child at layer - 1
keep an intermediate empty node if a gap has no key at that next layer
link the left gap as l and each subsequent gap as its preceding entry's t
prefix-compress this node's keys, starting the first key with prefix length 0
encode the node, calculate its CID, and return its block plus child blocks
The wire node uses l and e; entries use p, k, v, and t. Prefix lengths count bytes. Do not compress across nodes, drop necessary intermediate layers, or leave empty top layers after deletion. Use the reference MST implementation and serialization helpers as cross-checks, then official root fixtures as evidence.
A complete-tree reader verifies the referenced blocks and reconstructed paths. A partial-tree reader represents an unresolved subtree explicitly; it must not turn a missing subtree into an empty one. M5 can implement complete trees only. M9 must add the partial-tree operations needed for independent diff verification before stream interoperability is claimed.
Commit signing and revisions #
Represent the unsigned commit with did, version: 3, data (MST root link), rev, and prev: null. Serialize it, sign those bytes with the protocol's one SHA-256 hashing step, then add raw signature bytes and encode the signed commit to obtain its CID. prev must be present; it is not our application event cursor or a mandatory Git-style history link. Check the pinned commit definitions and repository implementation.
The crypto adapter's contract must say whether it accepts a message or an already computed digest. Pick message bytes for our public signing interface and hash exactly once inside the tested adapter. Account for OTP/library DER signatures versus the protocol's fixed-width r || s bytes, and the required low-S form. Normalize using verified curve parameters/library helpers and validate against official crypto fixtures; never accept "our verifier accepts our signer" as sufficient evidence.
TIDs encode a microsecond timestamp and a 10-bit clock identifier using a sortable alphabet; they are not ordinary CID base32 strings. Use a stable random clock identifier and choose each next timestamp as at least previous_timestamp + 1 if the clock repeats or moves backward. Preserve the last committed revision across restart/import. Validate width and overflow. The TID specification governs encoding. Event sequence numbers are a separate server-wide sequence.
The atomic repository-write pipeline #
The write service must implement these steps as one logical operation:
- Bound and decode the request; parse its identifiers and generic values.
- Authenticate the appropriate access mechanism and establish a typed principal with DID, active account state, and granted permissions. Resolve a supplied handle to the actual owned DID; never authorize by user-supplied
repotext alone. - Validate the operation-specific schema, record
$type, requested validation mode, record-key scheme, blob ownership/references, and explicit record/commit preconditions. - Obtain the write owner and current head/mapping. Apply the batch with the pinned operation semantics, preserving output ordering. Test repeated paths; do not silently deduplicate them.
- Encode changed records, rebuild the candidate MST, choose a newer revision, and create a signed candidate commit.
- Construct the exact candidate public event and CAR slice, and enforce serialized size/operation limits before the update can become durable.
- Begin the SQLite transaction and recheck account state, captured head, and compare-and-swap conditions if any work happened outside the transaction.
- Insert verified blocks; update the current record index and blob references; update the repository head; allocate the sequence number and store the completed public event body. These changes commit together. An event may be finalized with its allocated sequence inside the same transaction.
- Commit, then wake stream readers. Return the actual committed URI/CID/head metadata.
- If a step fails before commit, roll back and report its typed error. If notification fails after commit, replay/polling still finds the durable event. If the HTTP response is lost after commit, do not pretend the write rolled back; clients must inspect state or use the operation's preconditions before retrying.
For the first diff implementation, include all required current MST nodes and contextual previous MST nodes, changed record blocks, and the new commit. Including extra MST context can simplify verification for a small repository; exclude deleted/replaced record payloads. Independently test operation inversion against the previous root. Optimize excess context later, while retaining proof correctness and staying inside event bounds.
Record-write semantics to specify before implementation #
Read the canonical files listed here and write their exact contracts into the endpoint matrix:
createRecord: generated key when absent, collision behavior, optional expected commit, and response URI/CID/commit metadata.putRecord: creation or replacement, required key, and three distinct expected-record states: field absent, explicitly null, and supplied CID. Do not useOption(Cid)if it erases this distinction.deleteRecord: expected record/commit constraints and missing-record behavior from the pinned reference handler.applyWrites: discriminated create/update/delete input union, operation-specific requirements, atomic failure, and ordered typed result union.
Validation modes have different behavior: false skips application Lexicon validation; absent validates known schemas; true requires schema validation. All modes still enforce the generic data model, authorization, resource limits, and valid repository identifiers. Return the actual validation status when supported; don't report unknown schema data as fully validated.
Seed the application schema registry with post/profile and their reachable references. Post text limits include graphemes and UTF-8 byte length; Gleam's one string-length operation is not automatically both. Profile key self differs from a post's TID key. Image limits come from the actual pinned application schema, not an old tutorial. Read the post, profile, and image embed schemas.
Blob storage and cleanup #
For an upload: authenticate, stream into a server-generated temporary filename under the blob directory, enforce the byte limit while reading, determine/validate the MIME policy, calculate a raw-byte CID, then atomically install the immutable file and its owned metadata. Filename generation uses trusted data or the validated binary CID; never interpolate the user filename or record path. On a failed metadata write, leave a recoverable orphan or remove only that validated temporary artifact.
An uploaded but unreferenced blob is temporary and not automatically public. A record write verifies that every referenced blob belongs to that account and that its declared size/type matches actual storage and applicable schema constraints. The reference update is part of the record transaction. Identical content can have multiple record references and potentially multiple owners; ownership is not inferred from knowing a CID.
A cleanup job first identifies unreferenced expired uploads/orphans and records a deletion candidate. Recheck references immediately before removing a file, coordinate with the write owner, and account for backups in progress. A public blob endpoint serves only eligible media for an available account. Runtime recovery reconciles leftover temporary files and missing metadata; it must never delete all blobs merely because a query failed.
Firehose framing, cursors, and subscriber races #
Each binary WebSocket message has a CBOR header followed by a CBOR body. Normal header: op: 1, t: "#commit" (or the other subtype). Error header: op: -1, without t; send the error body and close. Don't wrap both objects in an array or JSON text. The framing specification governs this boundary.
Populate the required commit fields in the pinned subscribeRepos schema, including legacy required fields. Include prevData and operation prev data needed by current inductive verification. Check both CAR-byte and total-message limits before commit. Initial protocol limits checked during planning include 200 operations and 2,000,000 CAR bytes; refresh them at M9 rather than treating our HTTP body limit as the stream limit.
Cursor compatibility decision: use seq > last_processed_cursor, matching the reference sequencer. The event-stream prose currently says greater-or-equal, while the Lexicon calls the cursor the last known sequence and the reference implementation uses exclusive replay. Record this discrepancy and retain the tested reference-client behavior unless an updated contract resolves it. Add a test with events 1, 2, 3: reconnect from 2 delivers 3, not 2 again.
No cursor starts at the current position; cursor 0 starts at the oldest retained event. A cursor beyond the persistent high-water mark produces FutureCursor; an expired cursor produces #info / OutdatedCursor and replays the retained window. Consult the reference subscription handler. Persist the sequence high-water mark even when old events are removed, and keep it within safe interoperability integer bounds.
Eliminate the replay-to-live race with this simple approach: a subscriber owns its last delivered sequence and repeatedly queries durable events beyond it; notifications are merely an optimization. Query immediately when attaching and after notifications, with a bounded polling fallback. New events appearing while replay runs are obtained by the next query. Do not queue unbounded payloads in mailboxes. Test disconnect during replay and commit during subscription initialization.
7. Seed endpoint matrix #
Every XRPC route below is /xrpc/<NSID>. This is the first-release implementation inventory, expanded if the actual client/relay requires more. Copy all canonical schema details into docs/ENDPOINTS.md before implementing a row; this inventory doesn't replace optional fields and errors in those JSON files.
| NSID or path | Method / representation | Access | First card |
|---|---|---|---|
/health |
GET; small health JSON | Public; no secrets | M2.1 |
com.atproto.server.describeServer |
GET; JSON server DID/domains/capabilities | Public | M2.2 |
com.atproto.identity.resolveHandle |
GET; handle → DID | Public | M7.1 |
/.well-known/atproto-did |
GET; plain DID for a hosted handle's domain | Public; correct host routing | M7.1/M11.1 |
/.well-known/did.json when hosting a server did:web |
GET; DID document | Public | M7.1/M11.1 |
com.atproto.server.createSession |
POST JSON; access/refresh JWT + account identity | Password | M7.3 |
com.atproto.server.getSession |
GET; current identity/status | Access JWT | M7.3 |
com.atproto.server.refreshSession |
POST; rotated session tokens | Refresh JWT | M7.3 |
com.atproto.server.deleteSession |
POST; revoke session | Refresh JWT, not access JWT | M7.3 |
com.atproto.repo.getRecord |
GET; URI, CID when provided, generic record value | Public active repository | M8.1 |
com.atproto.repo.listRecords |
GET; record list + cursor | Public active repository | M8.1 |
com.atproto.repo.describeRepo |
GET; identity and hosted collections | Public active repository | M8.1 |
com.atproto.repo.createRecord |
POST JSON; new URI/CID | Authorized owned repository | M8.2 |
com.atproto.repo.putRecord |
POST JSON; URI/CID | Authorized owned repository | M8.2 |
com.atproto.repo.deleteRecord |
POST JSON | Authorized owned repository | M8.2 |
com.atproto.repo.applyWrites |
POST JSON; ordered results | Authorized owned repository | M8.3 |
com.atproto.repo.uploadBlob |
POST raw media; blob object | Authorized upload permission | M8.4 |
com.atproto.sync.getRepo |
GET; application/vnd.ipld.car |
Public active repository | M9.1 |
com.atproto.sync.getLatestCommit |
GET; CID/revision | Public active repository | M9.1 |
com.atproto.sync.getRepoStatus |
GET; hosting/availability status | Public | M9.1 |
com.atproto.sync.listRepos |
GET; paginated hosted repository summaries | Public | M9.1 |
com.atproto.sync.getBlocks |
GET; requested owned blocks in CAR | Public eligible repository blocks | M9.1 |
com.atproto.sync.getBlob |
GET; raw media with correct content headers | Public referenced/eligible blob | M8.4/M9.1 |
com.atproto.sync.listBlobs |
GET; blob enumeration for transfer | Appropriate account/status rules from schema | M9.1/M12.3 |
com.atproto.sync.subscribeRepos |
GET upgrade; binary WebSocket | Public bounded subscriptions | M9.3 |
com.atproto.server.getServiceAuth |
GET; restricted service JWT | Authorized service permission | M10.5 |
app.bsky.actor.getPreferences |
GET; private preference array | Authenticated permitted owner | M10.5 |
app.bsky.actor.putPreferences |
POST JSON; update private preferences | Authenticated permitted owner | M10.5 |
/.well-known/oauth-protected-resource |
GET; JSON metadata | Public + browser CORS | M10.1 |
/.well-known/oauth-authorization-server |
GET; JSON metadata | Public + browser CORS | M10.1 |
/oauth/jwks |
GET; public authorization-server keys | Public | M10.1 |
/oauth/par |
POST form; pushed request URI | Profile-defined client authentication + DPoP | M10.2 |
/oauth/authorize |
GET; login/consent UI and redirect | User browser session + valid pushed request | M10.3 |
/oauth/token |
POST form; access/refresh tokens | Code/refresh + client checks + DPoP | M10.4 |
/oauth/sessions and revocation action |
HTML/JSON management interface | Authenticated owner + CSRF protections | M10.4 |
| Application XRPC routes to configured AppView | GET/POST as canonical route allows | Principal + proxy permission + service JWT | M10.5 |
The OAuth path names above are our choices; publish them consistently in metadata. External OAuth clients follow metadata and must not depend on our chosen names.
For every route: verify HTTP method, content type, required params, number/boolean decoding, nullable/optional distinctions, success status/body, named errors, auth mechanism, scope checks, and account-state behavior. Return structured XRPC error bodies on XRPC paths. Map internal errors centrally; don't leak secrets, SQL, or raw library exceptions into responses. An unsupported endpoint remains an explicit error and a listed compatibility gap.
getRecord's optional CID means a requested version, not merely a decorative response field. Decide and test retained-version access consistent with the pinned reference and our deletion policy; report RecordNotFound for an unavailable requested version. getRecord schema.
listRecords is scoped to repo and collection, defaults to 50 and permits 1–100 records per request in the currently checked schema; it supports reverse direction and a cursor. Test both directions and the final page. listRecords schema.
describeServer must publish an actual server DID and domain/capability configuration. For the local closed-registration lesson, an empty domain list is acceptable development configuration; it is not evidence of working public identity. describeServer schema.
Session handlers have separate access/refresh-token validators. Access tokens must not be accepted for refresh/deletion and refresh tokens must not authorize record writes. This is explicitly required by the deleteSession schema.
8. Detailed lesson cards: foundation and repository #
Each M<number>.<number> below is a checkpoint. If it is too large for one learning session, split it in the learning log without changing its completion contract. Prerequisites are previous cards unless stated otherwise. Do not skip failed prerequisite checks to keep a timetable.
M0.1 — Bootstrap safely in the existing directory #
Explain: a Gleam package is configured by gleam.toml; src holds modules, test holds test modules, and the Erlang target runs on the BEAM. An import exposes a module; pub fn main is the entry function; Nil indicates no useful return value.
Actions: inspect the directory and applicable instructions. If a gleam.toml already exists, inspect it and resume instead of overwriting it. Otherwise create the following files using the environment's normal patch/editor mechanism. Manual scaffolding preserves this plan and avoids generating a nested pds/pds project or failing on an existing nonempty directory.
gleam.toml:
name = "pds"
version = "0.1.0"
target = "erlang"
[dependencies]
gleam_stdlib = ">= 1.0.0 and < 2.0.0"
[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
src/pds.gleam:
import gleam/io
pub fn main() -> Nil {
io.println("Hello from pds!")
}
test/pds_test.gleam:
import gleeunit
pub fn main() -> Nil {
gleeunit.main()
}
Also create the .gitignore described above, a README stating "local learning project; implementation in progress," and the log/decision templates. Add a first project-specific test only when M1 introduces a meaningful parser contract; the initial empty runner is sufficient to establish the toolchain.
Run from /Users/bradswain/projects/atproto/pds in this workspace, or the actual project root if the plan has moved:
gleam --version
erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().'
rebar3 version
gleam run
gleam check
gleam test
gleam format --check
Run independently; stop to inspect a failure. gleam run should print Hello from pds!. Dependency retrieval must succeed and produce manifest.toml. Keep the manifest. The test command currently exercises the runner, not a protocol feature; report that distinction.
Learner exercise: change the printed greeting and run it. Then temporarily change the declared return type to Int, observe why the compiler rejects the function, and restore the valid program. Explain that io.println returns Nil.
Evidence: tool versions, successful baseline commands, original type error explained and corrected. At planning time, the installed toolchain created a temporary template correctly, but compilation couldn't reach repo.hex.pm in the restricted environment. The executing model must resolve that environment requirement before claiming this card's build/test checks pass. Installing another compiler is not the remedy for blocked Hex access.
M0.2 — Prepare the learning and protocol references #
Actions: populate the source register and retrieve the two reference checkouts described in section 3 when network access is available. Read the AT Protocol overview and the language tour's basics. Record the package/compiler/runtime versions and source revisions. Explain a post's path through client, PDS, relay, and AppView with a concrete text post.
Learner exercise: describe which component owns the record and which component supplies a feed. Point to the function that currently runs in our project.
Evidence: the source register contains actual revisions, and the learner can explain the architecture. No public account, PLC operation, DNS write, or network announcement happens in this card.
M1.1 — Parse the shape of a record path #
Files: src/pds/syntax/repo_path.gleam, test/pds/repo_path_test.gleam.
Interface to implement: a custom RepoPath value with named collection/key fields and split_path(String) -> Result(RepoPath, PathError). Error variants describe wrong segment count or an empty segment. This first function is a structural teaching exercise, not the final protocol validator.
Explain: immutable bindings; constructors; named fields; exhaustive case; Ok/Error; strings versus lists. Show how the compiler requires callers to handle both results.
Learner exercise: implement the match over string.split(input, "/"). Try app.bsky.feed.post/example, a/b, a, a/b/c, /a, and a/. At this step a/b passes structurally; explain why M1.2 will reject its invalid collection.
Checks: exact two-segment success preserves both strings; extra/missing slash and empty segments return the correct variants. No string indexing that can fail on empty input. Test functions are public and end with _test so the runner actually discovers them.
M1.2 — Turn strings into validated identifier types #
Files: syntax modules for NSID and record key; update the public path parser to call them. Use opaque types with public parse/format accessors. Keep the structural helper private or clearly named.
Actions: read the exact grammars and official syntax fixtures. Implement all length/ASCII/segment rules. Add TID parsing/generation at M6; handle/DID resolution comes at M7. Record the distinction between syntactic validity and a resolvable identity.
Learner exercise: add a specific typed validation error and use it to explain an invalid input. Discuss why pre:fix is a valid record key and why self is useful.
Checks: path app.bsky.feed.post/example passes; a/b fails NSID validation; app.bsky.feed.post/., ../, whitespace, Unicode record keys, slash in a key, and 513-character keys fail. Test 1/512-character boundaries, underscore/tilde/colon, a camel-case collection name, and every imported relevant fixture. Don't invent an NSID grammar from these few examples.
M2.1 — Run a small HTTP server #
Dependencies introduced: gleam add mist gleam_http gleam_erlang logging; add JSON support with gleam add gleam_json when generating JSON. These are commands to resolve compatible versions, not permission to update existing locked dependencies without cause.
Files: pds/config, pds/http/server, pds/http/router; update pds.main to start the listener. Read the installed Mist example/API. Handle startup errors explicitly; don't let main exit immediately and kill the server.
Build: bind 127.0.0.1:2583, serve GET /health with status 200 and JSON such as {"status":"ok"}, and return an explicit unknown-route response. Keep response encoding separate from the transport. Add bounded request-body reading when accepting bodies.
Learner exercise: add one field to the health response and inspect it with:
curl --fail-with-body -i http://127.0.0.1:2583/health
Checks: status/content type/body; unknown route; unsupported method; server remains running for multiple requests. A known port-in-use failure is reported accurately. Health reveals no configuration secrets or account/session data.
M2.2 — Implement the first real XRPC contract #
Files: pds/http/xrpc_error, pds/api/describe_server; start docs/ENDPOINTS.md.
Actions: read and copy the describeServer schema contract. Implement a configured development server DID and domain list, central error encoding, and GET dispatch. Do not advertise working signup merely because the JSON contains domains.
Learner exercise: trace /xrpc/com.atproto.server.describeServer from the router to its handler and back to JSON. Implement a structured invalid-method error through the shared mapping.
Checks: canonical required fields and types; explicit error response; no successful invented response for other /xrpc routes. Record the endpoint as implemented locally, not independently verified.
M2.3 — Prove our transport supports the future stream #
Build: a development-only WebSocket experiment that sends or echoes a fixed binary frame at /dev/ws. It must pass through Mist before ordinary-body decoding. Use a tiny independent WebSocket client, with its dependencies recorded.
Learner exercise: distinguish the binary payload from a text message and from the HTTP upgrade headers.
Checks: upgrade succeeds; exact bytes are preserved; disconnect releases resources; ordinary HTTP still works. This is not subscribeRepos, and its route must be unavailable in production. If Mist integration fails, fix or isolate that integration here rather than discovering it after repository work.
M3.1 — Introduce SQLite and migrations #
Dependencies: gleam add sqlight; add an environment/filesystem helper only when needed and record its actual API/version.
Files: pds/storage/database, pds/storage/migrations, priv/migrations/001_dev_records.sql.
Actions: open only the configured data path; apply the development-table migration; enable foreign keys; create a storage API with typed database errors. Migration application and bookkeeping must be transactional. Detect a modified already-applied migration by checksum.
Learner exercise: explain a composite primary key and why SQL placeholders protect the value/SQL boundary. Insert one test record through a parameterized statement.
Checks: a fresh database is created; restarting does not reapply the migration; an intentional migration failure rolls back; a quote in a record value is stored literally. Close test connections and use isolated temporary databases, never the real runtime database.
M3.2 — CRUD and keyset listing #
Files: pds/storage/dev_records; focused storage tests. Initially exercise this module from tests or a local development command, not a public write endpoint.
Build: create/get/list/replace/delete by owner/collection/key, explicit duplicate/missing behavior, and keyset pagination. Persist valid JSON only in this temporary learning table.
Learner exercise: implement retrieval and translate an empty query result into a typed not-found result.
Checks: create then retrieve; restart then retrieve; replace same key; delete; duplicate create; wrong owner; multiple collections with same key; 0/1/many records; forward/reverse pagination with no duplicates in an unchanged dataset; malformed/cross-collection cursor. Document behavior for concurrent inserts/deletes between pages.
M3.3 — Transactions and the write-owner process #
Dependencies: introduce gleam_otp when the owner/supervisor is implemented.
Build: serialize database mutations through one typed command interface; retain a clear DB transaction API. Explain that an actor processes messages sequentially but its memory disappears on restart.
Learner exercise: make the second operation in a two-operation transaction fail and observe the first operation's rollback.
Checks: concurrent duplicate creates have one committed outcome; an injected transaction failure changes no rows; restarting the owner reopens durable state; busy/timeouts are bounded; request failures don't kill the whole application. Gate L can now be evaluated.
M4.1 — Generic data model and JSON boundary #
Files: pds/data_model, pds/encoding/json; official fixture adapter and data-model tests.
Build: the recursive value type described in section 4 and checked JSON conversions. Enforce signed integer bounds, valid bytes/links, finite depth/size, map-key rules, and the distinct absent/null semantics. Preserve unknown application fields allowed by the contract. Establish a deterministic duplicate-key policy at parsing rather than silently letting a dictionary hide ambiguous input.
Learner exercise: construct a small record containing a list, a null, and a nested map; pattern-match a value without assuming it is always a string.
Checks: nested round trips; bytes/link wrappers; true/false/zero/empty/null distinctions; negative and large integers; float rejection; invalid wrapper content; excessive nesting; unknown reserved fields handled according to the current data-model contract. Import actual fixture cases before declaring the decoder complete.
M4.2 — Varints and deterministic CBOR #
Files: pds/encoding/varint, pds/encoding/cbor; type-focused tests.
Build in small substeps: scalar encoding; strings/bytes; lists/maps; links; bounded decoder with consumed-byte count. An existing CBOR library may be used only after the fixture experiment proves it can emit the required profile. Our API still enforces AT Protocol's narrower value restrictions.
Learner exercise: annotate the bytes for a tiny object. For {"a":1}, the expected basic CBOR bytes are a1 61 61 01; explain the map size, string size, UTF-8 byte, and integer.
Checks: official exact-byte fixtures; key order independent of insertion; multibyte string lengths; boundary integer widths; nonminimal/indefinite encodings handled as the contract requires; truncated data; unknown tag; link prefix; duplicate keys; trailing bytes; depth/size budgets. A general decoder accepting ordinary CBOR is not sufficient evidence that our encoder is deterministic.
M4.3 — CIDs and immutable blocks #
Files: pds/encoding/base32, pds/encoding/cid; a narrow hash adapter; CID tests.
Build: validated binary/text CIDs and helpers for structured/raw content. Test the exact bytes fed to hashing. Add immutable in-memory block storage for the repository lessons; durable storage arrives in M6.
Learner exercise: change one record field, compare encoded bytes/digest/CID, then restore it and confirm the original CID returns.
Checks: official CID vectors; binary/text round trips; wrong version/codec/hash/length; truncated/extra bytes; invalid base32; structured versus raw codec; same CID/different bytes rejected at insertion. Do not create a CID from a generated UUID or arbitrary hash string.
M5.1 — Understand key layers and an empty MST #
Files: pds/repo/mst; key-layer/node tests.
Build: key-layer hashing, wire node types, empty-root serialization, and node prefix compression. Read the official MST fixture formats and the reference helpers first.
Learner exercise: examine three paths with different layers; explain which key belongs nearer the root. Hand-decompress two adjacent entry keys and explain why byte prefixes matter.
Checks: fixture layer examples and exact empty root; first entry prefix 0; shared-prefix reconstruction; non-ASCII bytes rejected where path syntax requires ASCII; malformed prefix lengths; correct null versus omitted node fields.
M5.2 — Build and read a complete deterministic tree #
Build: the rebuild algorithm in section 6 and a validating traversal back to a mapping. Add pure lookup/create/replace/delete through the mapping-to-tree interface. No performance optimization yet.
Learner exercise: insert the same three records in a different order and predict the root; delete the highest-layer key and inspect the new top node.
Checks: official root fixtures; all permutations of a small mapping; empty/single/many entries; identical value at different paths; adjacent shared prefixes; missing intermediate layers; deletion to an empty tree; path ordering; block tampering; missing referenced node; cycle/budget handling for hostile input. A tree that works for one post is not enough.
M5.3 — Run fixture coverage and differential tree checks #
Files: fixture adapters and integration/ tree comparison tool.
Actions: import every relevant official MST case; report any unsupported fixture type. Add a deterministic generated sequence of inserts/replacements/deletes, store its seed, and compare final mappings/roots with the pinned independent implementation. Use actual exported APIs discovered from the pinned package's type declarations/tests.
Evidence: machine-readable coverage counts and matching roots, including awkward deletion cases. If a random case fails, minimize it into a small reproducible regression fixture instead of changing the random seed.
M6.1 — Crypto adapter and key persistence #
Dependencies: introduce jose and verified OTP adapters. The server must initialize the OTP applications needed by the installed libraries; inspect generated application/release metadata rather than assuming imports start everything.
Files: pds/auth/crypto, pds_ffi.erl, key-storage adapter; crypto tests.
Build: secure random generation, SHA-256, P-256 signing, verification for both required curves, multikey encoding, and private key save/load. Read library documentation and official crypto fixtures. Do not implement elliptic-curve arithmetic or invent signature normalization.
Learner exercise: explain why a public key can verify but cannot produce our signature, and why changing one byte invalidates verification.
Checks: both curve vectors, wrong message/key, malformed point/signature, high-S policy, raw/DER conversion, compressed public keys, exactly one hash step, error translation through FFI, mode-0600 persistence, and no regenerated key on restart. Never print private key bytes in a test failure/report.
M6.2 — TID revisions and signed commits #
Files: pds/syntax/tid, pds/repo/commit, durable head/block migrations.
Build: injected clock, fixed clock identifier, monotonic persisted revisions, unsigned/signed commit encoding, and storing a verified head plus all reachable blocks. Use a local fixture account DID. Public identity creation is still deferred.
Learner exercise: freeze or move the test clock backward and explain why the next revision must still increase. Point to which fields are present when signing and when calculating the signed commit CID.
Checks: same microsecond; backward clock; process restart; imported prior revision; future/overflow policy; required prev: null; no sig in signed input; correct data root; changed signature/value fails verification. A second repo write must preserve the first committed state if the next transaction fails.
M6.3 — CAR import/export and independent repository verification #
Files: pds/encoding/car, pds/repo/export, pds/repo/import; integration/verify_repo tool and fixtures.
Build: archive serialization and bounded import validation. The initial full export contains the signed commit, complete reachable MST, and current records; media bytes are transferred separately. Import into isolated staging, verify DID/key/root/blocks, then install atomically; do not mutate active storage while validation is incomplete.
Independent verifier contract: accept a CAR file and the expected DID/public key; use the pinned reference implementation to read blocks, verify every relevant hash and the commit signature, enumerate record paths/CIDs, and report the commit/MST root/revision. Exit nonzero on any mismatch. Use reference type declarations/tests to select real APIs. Print public metadata only.
Checks: our export verified externally; external fixture read internally; shuffled blocks; allowed duplicate blocks; truncation; bad length/CID/root/signature; missing reachable block; harmless external link versus missing tree node; unrelated extra blocks handled without contaminating another account. Record logical/byte comparison separately because CAR block order and signature bytes may vary legitimately. Evaluate gate R after public read endpoints are connected in M8.1.
9. Detailed lesson cards: identity, writes, and synchronization #
M7.1 — Identity resolution and handle verification #
Files: pds/identity/did, pds/identity/handle, typed HTTP/DNS adapter; identity tests.
Build: parse did:plc and did:web distinctly, resolve their documents through test fixtures first, and extract the account's #atproto verification method and #atproto_pds service. Validate document identity, allowed key encodings, service type/origin, and required structure. For handles, implement the documented DNS TXT and HTTPS well-known methods and verify the DID document claims at://<handle> as well. Cache with expiry/invalidation and bounded network requests.
Learner exercise: explain why a handle can change while the DID stays the same, and why handle → DID alone is insufficient.
Checks: both DID methods; supported key encodings/curves; document ID mismatch; missing/wrong service; malformed handle; conflicting DNS responses handled by the documented rules; well-known body validation; mismatched reverse handle claim; cache expiry; remote timeout; safe fetch policy. Local fixture resolvers are injectable and unavailable in production.
M7.2 — Provision a local account and prepare public identity #
Files: pds/admin, pds/identity/plc, account/key migrations, provisioning tests.
Add argv for the local CLI and an explicit environment reader such as envoy when needed; inspect their installed docs. Our CLI contract will be gleam run -m pds/admin -- <command> [options], with commands provision-local, prepare-identity, backup, restore, and later migrate-test as their cards implement them. --help lists actual implemented commands; unknown commands exit nonzero. Paths/identifiers can be arguments; secret values cannot. prepare-identity creates a local review artifact, not an external identity write. Backup/restore destinations are explicit, validated paths and existing destinations are not overwritten by default. These commands do not exist at planning time.
Build: a local admin command that creates durable keys, password hash, account state, and a signed empty repository. Accept secrets through an interactive input or protected file, not command-line arguments that appear in process listings. Provisioning is idempotent by explicit account/DID identity; it must not overwrite an existing account or key when rerun.
For PLC: retrieve and pin the method repository; read genesis/update operations and recovery-key semantics. Implement or reuse a tested operation builder and verify it against that method's fixtures. Prepare the genesis operation linking the desired handle, PDS endpoint, signing verification method, and separately held rotation keys. Follow the method's actual DID derivation/signature rules; don't derive the DID by casually hashing our public key. Store the operation and resulting public metadata in a reviewable non-secret artifact.
Public boundary: preparation and local fixture provisioning can be completed now. Publish the genesis operation only after a real hostname/handle and authorization to create the disposable public account exist. If that hasn't happened, record public identity pending M11.2; it doesn't block further fixture-based lessons. Never use the user's existing account as the fixture.
Checks: provisioning/restart retain one DID and key; repeated provisioning fails safely or returns the same known state; password hash is verifiable; empty CAR verifies; prepared PLC operation passes independent method validation; no rotation key appears in logs or repository exports.
M7.3 — Password sessions with distinct token roles #
Files: pds/auth/password, pds/auth/session, session handlers and storage; session contract tests.
Build: password verification, durable session identity, access JWT issuance/validation, refresh JWT issuance/validation, refresh rotation, session introspection, and revocation. Parse JOSE headers/claims under an algorithm allowlist; validate expiry, audience, subject, role/scope, signature, and active account as appropriate. Use a durable server secret, not the account's repository key, for the initially chosen symmetric password-session tokens.
Use short access-token lifetimes as our policy, and separately bounded refresh lifetimes. Track the session/refresh identity in storage so revoked sessions and consumed refresh tokens fail. Include an explicit session identifier in issued tokens when the implementation needs it. Check required response fields in the pinned schemas, including DID/handle. Match legacy scope/type conventions used by the reference token helpers and scope definitions.
Learner exercise: show why a token having valid JSON is different from having a valid signature and authorized scope. Trace a refresh request separately from a record write.
Checks: successful login with fixture identity; wrong password; unknown account; malformed/modified/expired JWT; wrong audience/subject/algorithm; alg: none; access token at refresh/delete endpoints; refresh token at a record endpoint; refresh-token replay; two concurrent refreshes; logout; restart. Don't log bodies or authorization headers. If a client requires app passwords, add their storage/scopes and actual handlers as a documented client requirement rather than treating any password-looking string as an app password.
M8.1 — Public read endpoints over the signed repository #
Files: repo-read service, getRecord/listRecords/describeRepo handlers; read-endpoint tests.
Build: resolve the supplied account identifier to an owned DID, enforce repository availability, fetch current records through the canonical mapping/block bytes, and convert protocol values to JSON. Include the actual AT URI/CID and known collection list; check optional requested-version behavior and pagination contract.
Learner exercise: trace one GET from query decoding to validated identifier to stored block to JSON. Explain why the stored record content doesn't itself need to contain its AT URI.
Checks: exact response schemas, absent/wrong params, valid/invalid limit/cursor, reverse ordering, missing record, unavailable requested CID, unknown account, inactive account, collection ownership. Export and compare the read values/CIDs with the independent verifier. Gate R is eligible after M6.3 plus these checks pass.
M8.2 — Single record writes and compare-and-swap #
Files: pds/repo/write, single-write handlers, commit/outbox migrations; write contract tests.
Build: the atomic pipeline from section 6 for create/put/delete. Distinguish absent/null/CID preconditions in types; use real account authorization. Introduce the durable event table now even though streaming arrives at M9. The record and outbox must not become separate transactions just because their APIs are taught in different lessons.
Learner exercise: submit a replacement with an outdated expected CID and explain why it fails without changing the current record.
Checks: generated/explicit keys, actual URI/CID, create collision, replace, deletion, all swap states, wrong account, inactive account, every validation mode, type/collection mismatch, unknown schema, malformed data model, rollback at each persistence stage, monotonic revisions, and independent CAR verification after success/failure. Successful HTTP output must describe the committed state, not a candidate that later failed.
M8.3 — Atomic batches and schema validation #
Files: batch decoder and operation types, schema registry/validator, batch-result encoder.
Build: the actual tagged input/output unions and sequential operation semantics from the pinned handler. Implement the Lexicon subset needed by registered schemas: required/nullable fields, scalar constraints, arrays/maps, refs, unions, string formats, record key schemes, blob constraints, UTF-8/grapheme limits, and transitive definitions. Register a schema only when all its required constraints are implemented; otherwise it is unsupported, not "validated."
Learner exercise: construct a two-write batch whose second record is invalid and confirm the first record was not published. Explain the difference between an HTTP batch and two separate writes.
Checks: mixed create/update/delete; ordered results; duplicated paths under pinned semantics; missing required union fields; closed-union unknown type; all-or-nothing failure; expected commit mismatch; more operations than allowed; final serialized event too large; a Unicode post at the grapheme/byte boundaries; an unknown record type under each validation setting. Never split one requested atomic batch into multiple committed transactions to fit event limits.
M8.4 — Upload and publish a referenced image #
Files: blob upload/storage service, uploadBlob and getBlob handlers, reference metadata; image fixture.
Build: the lifecycle in section 6; add images to the post schema and profile media separately. Begin with a small known local PNG/JPEG fixture. Raw upload bytes are not multipart form data unless the canonical endpoint explicitly says otherwise. Return the canonical blob wrapper, then reference it in a signed record.
Learner exercise: upload a file, inspect its returned raw CID, write a record referencing it, and explain why the media itself isn't stored as an MST record.
Checks: unreferenced upload state; publishable reference; exact size/type/hash; missing/other-owner blob; over-limit streaming abort; disallowed MIME; wrong reported reference size; repeated upload; multiple references; delete one/all references; private temporary upload is not publicly fetched; server restart; interrupted file/metadata installation. Verify record export plus separate media retrieval independently.
M9.1 — HTTP synchronization and backfill reads #
Files: sync read handlers/services and endpoint contracts.
Build: full repo export, latest commit, repository status, account enumeration, owned block retrieval, and blob enumeration/retrieval from one coherent committed view. Decide optional since/retained-version behavior from the pinned schema/reference; support it accurately or record the actual supported limitation and response. Never serve arbitrary global blocks across account boundaries.
Learner exercise: compare a current repo head response with the root commit in a downloaded CAR. Identify why inactive repositories differ from unknown ones.
Checks: required fields/types/content headers; pagination; block CID verification; requested missing blocks; inactive/deleted account status; old block policy; a concurrent write during export; archived record versus blob reference; public private-key/session data never present. Check whether the intended relay additionally calls an endpoint such as a legacy compatibility route and document its actual need.
M9.2 — Construct independently verifiable event bodies #
Files: pds/repo/diff, partial-MST support, pds/sync/event; event fixtures.
Build: exact commit/identity/account/sync bodies, CAR slices, previous-root metadata, per-operation prior CIDs, and framing. Required legacy flags remain present with their current intended values; don't follow an obsolete tooBig design instead of current size handling. A reset/import uses a real sync event with consistent commit metadata.
Learner exercise: annotate a single create event: old revision, new revision, commit CID, operation path/CID, and CAR contents. Then identify the extra information an update/delete requires.
Checks: official firehose fixtures; independent event parser; inclusion verification; operation inversion reproduces the previous root; creation/deletion/re-creation of identical data; moved content; adjacent-key deletions; empty commits; canonical required fields; missing context; incorrect prevData; limits on operations, CAR bytes, individual record bytes, and complete serialized message. Record the current total-message limit from the sync spec too.
M9.3 — Subscribe, replay, and supervise #
Files: pds/sync/subscriber, event queries, websocket dispatch; subscription integration tool.
Build: persisted sequence assignment, cursor parsing, replay and live delivery according to section 6, bounded subscribers, error frames, and shutdown. A no-cursor subscription must establish its starting high-water mark without losing a write that races with its creation. Supervise database owner and stream workers; recovering a process must not regenerate identity or reset sequences.
Learner exercise: disconnect after event 2, write event 3, reconnect with cursor 2, and verify only the missing event is delivered. Explain how the database makes this possible after a restart.
Checks: no cursor; 0; valid cursor; expired cursor; future/negative/malformed cursor; empty event log with retained high-water mark; replay/live race; commit without notification; slow/disconnected consumer; multiple subscribers; websocket bytes rather than JSON text; proper GET/Upgrade failures; retained events after restart. Reconstruct final state in an independent consumer and compare its head with getLatestCommit. Gate S requires this evidence.
M9.4 — Fault exercise and stream-retention policy #
Build: test-only fault injection before/after key write boundaries and controlled event pruning. Run crashes in a disposable instance with copies of test data. Never kill a user's unrelated process or truncate a real database for the exercise.
Learner exercise: predict the observable state for a crash just before commit and one just after commit but before notification.
Checks: before commit leaves no partial mutation/event; after commit is recoverable by replay even without a success response; pruning doesn't reuse sequence numbers; account deactivation/deletion suppresses redistribution as required and emits the appropriate status event; retained public events contain no private tokens/preferences; block cleanup doesn't break retained proofs. If recovery is inconsistent, fix it before public deployment.
10. Detailed lesson cards: OAuth and client compatibility #
OAuth is an authorization protocol with several independently testable parts. Our provider is not complete because we generated JWTs or built a login form. Implement these cards with an independent client driving the flow, and retain the negative cases. Use an established provider component if it satisfies the profile; JOSE alone is a signing library, not a complete OAuth authorization server.
Before coding, prepare docs/protocol/OAUTH.md with the metadata fields, request/response parameter names, auth methods, challenge status/body/headers, token binding, and lifecycle rules from the AT Protocol profile and the RFCs linked below. Record the actual test client's metadata/scopes. Keep OAuth errors distinct from XRPC errors; the two protocols do not share one arbitrary error vocabulary.
M10.1 — Discovery, keys, and DPoP proof verification #
Files: OAuth metadata/JWKS endpoints, pds/auth/dpop, nonce/replay storage; metadata/proof tests.
Environment: use a dedicated test HTTPS origin with a certificate trusted by the independent client, or a properly configured isolated TLS test environment. Configure a test CA explicitly if needed. Do not globally disable TLS verification. Missing hostname/hosting authority prevents the public setup, but unit tests with captured metadata/proofs can continue.
Build: protected-resource metadata pointing to our authorization-server origin, authorization-server metadata publishing our real endpoints/capabilities, and public JWKS containing no private key material. Use a distinct durable OAuth key and an allowlist of supported JOSE algorithms. Metadata must be consistent with external URLs through the reverse proxy, and have the required status/content type/CORS behavior. Check every mandatory field from the authorization-server metadata standard and AT Protocol profile.
Implement DPoP verification as a reusable typed service, based on RFC 9449: verify proof signature/type/allowed key, method, URI, time, unique JWT ID, server nonce, and access-token hash where required. Bind tokens to the public-key thumbprint. Build the expected URI from configured public origin and validated request path, accounting for the RFC's query/fragment rules; arbitrary Host or forwarded headers aren't authoritative. Accept the mandatory P-256 algorithm; don't let the token choose an unchecked algorithm.
Use short-lived server nonce generations, accepting a bounded previous generation to accommodate concurrent requests while staying inside the profile's maximum nonce lifetime. Record the challenge's endpoint-specific HTTP status, error body, DPoP-Nonce/authentication headers, and browser exposure headers in the contract. A challenge retry uses a fresh signed proof; don't cache a single proof as a reusable bearer token. Reserve replay IDs atomically once a proof is accepted, and expire them only when replay is no longer possible.
Learner exercise: draw the distinction between the account repository key, the server OAuth key, and one client's DPoP key. Decode a public proof header/claims and predict a method-mismatch failure.
Checks: independent discovery succeeds; incorrect origin/issuer/metadata types fail; no private JWK fields; valid proof and fresh nonce; missing/stale nonce challenge and retry; wrong method/URI/key/token hash; expired/future proof; replayed JWT ID; concurrent duplicate proof; invalid signature/algorithm; nonce rollover; CORS preflight and exposure headers. Run the proof checks at both authorization and resource-server boundaries where required.
M10.2 — Permissions, client metadata, and pushed requests #
Files: permission parser/evaluator, metadata fetcher/cache, /oauth/par handler, request storage.
Build: parse requested scopes into explicit permission values. Required atproto establishes the protocol session; it does not itself authorize every record write. Support the granular repo/blob/RPC permissions needed by our test client. Support transitional scopes only with their actual documented restrictions if the selected client needs them. Unknown unsupported scopes are rejected or explicitly excluded with consent/response behavior allowed by the profile; they never silently become full access.
For permission-set inclusion, resolve the pinned schema/authority, expand grants with bounded depth/size and cycle detection, and apply the actual inheritance rules. If a selected client requests a set we can't expand, this is a visible compatibility blocker, not a reason to grant everything. Use the permissions specification to build tests for collection/action, upload MIME, and service/method restrictions. Authorizing putRecord requires the action actually performed (create or update); each batch operation must be covered.
Begin with an explicit configured allowlist of known OAuth client IDs. Retrieve metadata with size/time limits, validate its declared client ID, redirect URIs, grant types, auth method, scope declarations, and public keys/JWKS as appropriate. Do not trust display names or logos as verified identity. Harden outbound fetching before enabling arbitrary client metadata: block inappropriate schemes/private destinations, validate redirects and DNS/address behavior, and never forward application credentials to metadata URLs.
Implement PAR as an HTTPS form-encoded request following RFC 9126. Validate client authentication, proof/nonce binding, response type, PKCE S256 challenge, state, exact registered redirect, and scopes. Support public clients and the confidential-client private_key_jwt method required by the profile; check assertion signature, issuer/subject, audience, expiry, and replay identity. Store a short-lived pushed request bound to these values and the proof key. Return an unpredictable request URI and lifetime using the canonical response/status.
Learner exercise: compare a create-only permission with full repository permission, then try changing an authorized redirect URI by one character.
Checks: valid public/confidential requests; metadata ID mismatch; invalid redirect; URI with credentials/fragment where forbidden; unsupported grant/scope/auth method; expired/wrong-audience/replayed client assertion; missing PKCE/state/DPoP; unknown request ID; overlarge/slow metadata; permission-set cycle; proof key bound to the pushed request; no SSRF through untrusted metadata URLs. Never accept client input asserting itself to be "confidential" without signature validation.
M10.3 — Login, consent, and authorization codes #
Files: small escaped HTML login/consent views, browser-session and CSRF service, authorize handler, code storage.
Build: load the stored pushed request by its opaque URI and expected client; reject expiry/reuse. The authorization request's later query parameters cannot override the stored redirect, scopes, PKCE challenge, or proof key. Use the existing password verifier to establish an HTTPS-only browser session with appropriate secure/HttpOnly/SameSite cookie settings and CSRF protection for state-changing forms.
Show the known client ID and requested permissions clearly. Honor login_hint and account binding. Approval records the approved subset and creates one unpredictable short-lived authorization code bound to account, client, redirect, PKCE, and DPoP key. Redirect only to the registered URL, with the exact state and issuer binding expected by the profile. Denial returns the appropriate authorization error through a validated redirect; an unvalidated redirect is not used for reporting an error.
Learner exercise: explain consent separately from successful password verification, and reject a request for an account that differs from the login hint.
Checks: no browser session; valid session; incorrect password; escaped untrusted strings; CSRF failure; consent approve/deny; mismatched account/client/request URI; expired request; refresh/back-button reuse; requested versus approved permissions; exact callback/state/issuer; no code/session material in logs. A UI that auto-approves all requests is not the default.
M10.4 — Code exchange, access checks, refresh, and revocation #
Files: token handler, OAuth session store, protected-resource authenticator, session-management UI/action.
Build: validate the form-encoded code exchange, exact client/redirect, PKCE verifier, client authentication, DPoP key/nonce, and code expiry/one-use state. PKCE validation follows RFC 7636. Issue random opaque access/refresh tokens and store only their hashes plus durable bindings/expiry. A token response includes the actual account DID, granted scope, token type, and lifetime according to the profile. Never reuse legacy password-session tokens as OAuth tokens.
At resource requests, resolve the token to the principal/grant, verify DPoP including access-token hash, check account/session state, then authorize the operation. Public read endpoints remain public where the protocol says so. Use the expected authorization scheme and profile-specific challenge behavior, not just Bearer handling copied from password sessions.
Refresh rotates the refresh token atomically, keeps the session's client/proof/account/grant binding, and rejects replay or an unauthorized grant expansion. Choose a documented token lifetime; our initial policy is 5-minute OAuth access tokens and bounded refresh sessions. Revoke sessions through an owner-authenticated CSRF-protected interface. Short access-token expiry alone is not the same as implementing revocation. Follow the profile and OAuth security practice.
Learner exercise: compare two clients holding token strings and explain why the one without the bound proof key cannot use the token. Refresh once, then reuse the old refresh token.
Checks: independent code exchange and authenticated write; wrong PKCE verifier, client, redirect, proof key, or account; expired/used code; insufficient per-action permission; missing token hash; token theft without proof key; refresh rotation/replay/concurrency; session revocation; restart persistence; code/session expiry; no grant widening. Public and confidential clients both have end-to-end evidence before full OAuth-profile support is claimed.
M10.5 — Service authentication, private preferences, and AppView proxying #
Files: pds/auth/service_token, configured service resolver/proxy, private-preference service and handlers.
Build: service JWTs signed by the account's repository key, bound to the verified service audience and exact XRPC method, with short allowed expiry. Honor the requested permission and canonical getServiceAuth contract; never let it become an unrestricted signing oracle. Validate service DID/document/fragment and endpoint before proxying. Use current kid, issuer, audience, and method rules from XRPC. The spring-2026 audience transition means old helper code may use a bare DID; default to the current full service reference and document/test any explicit compatibility mode required by our selected upstream. Never interpret a missing fragment as a wildcard grant.
Route ordinary AppView calls to the configured default or an explicitly permitted resolved target from atproto-proxy. Enforce method/path/destination permissions, preserve relevant request semantics and upstream status/content headers, bound response/time, and strip hop-by-hop headers. Replace incoming client auth with the freshly generated service token. Don't forward client cookies, password tokens, or OAuth DPoP proofs to an unrelated upstream.
Bluesky's private preferences are an explicit local exception to generic AppView proxying. Implement app.bsky.actor.getPreferences and putPreferences for the configured application, with owned private storage, canonical types, and the correct endpoint permissions/filtering. Changes must persist and must not create public repository records or events. The reference read handler, write handler, and their canonical Lexicons govern behavior. Don't return a permanent empty preference list merely to get past client initialization.
Learner exercise: trace an authenticated profile read versus a private preference update and identify why only one goes to the AppView.
Checks: upstream profile/feed calls; service signature/audience/method/expiry; wrong target/key; unsupported proxy destination; incorrect permission; upstream timeout/error; no client-secret forwarding; preference write/read/restart; denied preference access; private data absent from CAR/firehose. Add the actual client-initialization endpoints observed in M10.6 to the matrix and implement any required missing local behavior.
M10.6 — Observe real client requirements before public federation #
Build: capture a sanitized request inventory from the chosen Bluesky web client and independent OAuth client. Record the client version/date, login mechanism, requested scopes, discovery documents, initialization endpoints, proxy headers, account status requirements, and supported features. Keep credentials/tokens out of captures.
Checks: metadata/login/code/token/session behavior where applicable; authenticated record write; media upload; preferences; profile/feed requests against a test or actual permitted AppView; refresh/revoke; CORS. If the client requires an unimplemented feature, create a precise additional card with canonical source, auth/storage semantics, and evidence requirements. A missing account/preferences endpoint is not repaired by returning a fake successful response. Gate C remains pending the observed public indexing checks at M11.
11. Detailed lesson cards: public integration and operations #
M11.1 — Prepare and verify a reproducible deployment #
Missing user values: a dedicated PDS hostname, desired test handle, deployment host/provider, DNS authority, and permission to publish this disposable service/account. Collect them before dependent external writes. Use any existing authorization; don't repeatedly ask for permission already granted. Do not choose a paid service or modify unrelated production routing on the user's behalf without that scope.
Files: deploy/ release/build recipe, unprivileged service definition, environment example, TLS reverse-proxy template; docs/DEPLOY.md.
Build: run gleam export erlang-shipment, package build/erlang-shipment, and start its documented entrypoint.sh run. Verify the generated entrypoint and runtime requirements. Build on Linux with a compatible OTP/architecture and SQLite native dependencies; do not copy macOS-compiled native libraries to Linux. A container can provide the build environment if available, but Docker is not required to start the course. Follow the Gleam deployment guide for shipment mechanics, replacing its example tool versions with our actual locked ones.
Use separate release directories and a persistent data directory owned by an unprivileged service user. The service specifies working directory, environment/secret paths, restart behavior, and bounded shutdown. Caddy is the proposed TLS reverse proxy; generate a dedicated site block forwarding this hostname to loopback port 2583. If the host already uses another proxy, adapt to that existing configuration instead of replacing unrelated sites. Verify installed proxy syntax and test configuration before applying.
DNS points the dedicated hostname to this host. Only add an IPv6 record if the service works over IPv6. Open the intended public HTTPS path; keep internal listener/database/key paths private. Set PDS_PUBLIC_URL and trusted proxy handling consistently; raw forwarded headers from arbitrary peers do not set identity or OAuth URLs. The public proxy must support WebSocket upgrades and suitable time/body limits.
Checks: shipment starts on the target platform, reads intended configuration, and finds migrations/Lexicons at runtime; health and XRPC work over HTTPS; wrong hostname does not expose another user's DID; metadata/DID endpoints have valid MIME/status; wss upgrades stay alive and replay; logs redact secrets; keys/data survive service restart; a backup exists before the first deployment migration. Save exact commands with real approved values in docs/DEPLOY.md, including rollback and verification commands. Never run template placeholders literally.
M11.2 — Publish the disposable identity and request discovery #
Prerequisites: target values/authority, public HTTPS and handle routes, prepared verified PLC operation, and a durable recovery-key backup under intentional custody.
Use a fresh production data directory and the keys already bound into the prepared public identity. Do not copy the lab's fixture identities, stub records, sessions, or resolver overrides into production. After directory publication, provision local account/repository state for that verified DID using its matching prepared signing key; don't generate a different key during this final step. Record a resumable provisioning state so directory success followed by a local failure can be reconciled safely.
Actions: publish the prepared new identity through the chosen directory's documented operation API; verify the returned DID against the prepared operation; publish the handle's DNS TXT or HTTPS well-known mapping; independently resolve handle → DID → PDS/key and its reverse handle claim. The server's service DID and account DID are distinct. Update local account configuration only from verified returned identity data.
Then check the intended network's current crawl/discovery procedure. For relays supporting it, com.atproto.sync.requestCrawl is a request to the relay with our actual hostname, not an instruction to implement the relay inside the PDS. Record the exact relay URL, request/result, and any current eligibility requirements. Requesting crawl is not evidence that indexing succeeded.
Checks: directory identity/key match; both handle directions resolve from an independent resolver; relays can fetch public repo/status/blob routes and connect to the stream; account is active; clock/revisions are accepted; no fixture resolver remains enabled. If a public operation partly succeeds, inspect external state and resume idempotently instead of generating a new identity on each retry.
M11.3 — Run the end-to-end Bluesky acceptance scenario #
Scenario: sign in to the selected client using the new test account and our PDS; create a profile; publish a unique text post; upload and publish an image with alt text; retrieve the profile/feed through the PDS; observe the post via an independent public AppView query. Record timestamps, public AT URIs, CIDs, revision, and stream sequence; capture no tokens.
Update and delete a test record through the API and verify repository and stream state independently. AppView behavior may differ by record type; document actual downstream observations rather than assuming every application exposes edits. Restart the service and repeat reads plus a cursor resume. Exercise token refresh and private preference persistence.
Checks: each stage has an actual result, not an inferred one. If indexing fails, inspect in order: public identity and handle; account status; independent CAR validation; stream connectivity/framing/proofs; relay's actual receipt/rejection; AppView indexing/status. Use bounded observation intervals and logs. A reachable health endpoint doesn't establish federation. Upstream policy/unavailability is recorded separately from our reproducible protocol failures. Gate C passes only for the documented tested client/network/features.
M12.1 — Implement coherent backup and fresh-instance restore #
Files: local admin backup/restore commands, docs/BACKUP_RESTORE.md; restore tests.
Build: pause writes and coordinate garbage collection, snapshot SQLite using its supported backup method (or an equivalent correctly stopped snapshot), inventory/copy referenced blob files, and capture necessary private keys/configuration in a protected backup artifact. Include a manifest of file checksums, account DID, head/revision, event high-water mark, schema version, and tool/release versions. Treat PLC recovery/rotation material separately from ordinary online signing-key backups and document its custody.
A live SQLite main-file copy without its transactional/WAL state is not the backup procedure. Secret backup artifacts need controlled access and documented encryption/key handling; never commit them to the repository. CAR alone lacks blobs and private account state.
Learner exercise: identify which pieces are missing from a CAR-only backup and explain why restoring a password doesn't recover control of an independently lost identity key.
Checks: restore into a fresh isolated data directory; validate schema/hash manifest and all current reachable blocks; verify the same account DID/key/head; retrieve every referenced image; validate session behavior according to the restore policy; reconnect/recover stream sequence without silently reusing old numbers. State whether sessions are retained or intentionally revoked. Restore must not overwrite live data by default. Gate O requires a demonstrated restore, not just a backup file.
M12.2 — Account status and key rotation #
Build: account deactivate/reactivate behavior, availability enforcement on read/write/sync/blob paths, and corresponding persisted events. Add the client/account-management endpoints required by the selected workflow with their canonical auth/permission contracts; keep closed registration. On deletion, follow the actual lifecycle policy, suppress stale redistribution, and stage recoverable cleanup appropriately.
Implement signing-key rotation as a planned operation: new durable key, verified identity update under the correct rotation authority, new commit verifiable with the current document, identity/sync notification, and crash recovery across the non-atomic external-directory/local-database boundary. Rotation is not accomplished by replacing a key file silently. Test intermediate states with fixtures first.
Checks: inactive account is not served as active; public status is accurate; owner recovery access follows the allowed rules; events tell downstream consumers to update; key change verifies independently; old-key cleanup doesn't destroy needed recovery evidence. Test before public rotation; never exercise this on an existing non-test account by default.
M12.3 — Migration and portability exercise #
Build: repo/blob export and validated staged import, compatible local account state/private preference transfer where applicable, and the real account-control APIs required by the chosen migration path. Prepare a second disposable server instance, not a second DID.
Sequence the exercise: verify a complete backup; provision destination keys/account under proper identity authority; import and validate repo plus blobs; preserve the current revision and choose any next revision above it; update the DID's PDS/key references with the correct recovery/rotation authority; switch handle mapping only when needed; activate the destination and emit consistent sync/identity/account state; retire source publication according to the protocol. Check the current account specification and reference migration workflow before external changes. Don't assume uploading a CAR alone moves an account.
Checks: same DID and record URIs before/after; current signature resolves correctly; blobs and private preferences survive as intended; selected client discovers/logs into the destination; independent consumer resumes or resynchronizes accurately; old host doesn't claim a new active repository for the migrated identity; failure before cutover has a documented recovery path. Record any migration APIs not yet supported before claiming general portability.
M12.4 — Operational handoff #
Files: completed README, deployment/restore runbooks, endpoint matrix, limitations list.
Build: counters for requests/errors, write latency, current head/revision, event backlog/oldest retained sequence, subscribers, disk usage, cleanup failures, and authentication failures without secret payloads. Configure bounded requests, subscriptions, login concurrency, network fetches, and orderly shutdown. Distinguish liveness from readiness; readiness requires the database/migrations/keys and configured services needed by the supported mode.
Checks: service can be started/stopped/restored by following the runbook; backup is restorable; limits fail predictably; shutdown closes DB/listener without partial writes; failed dependency/init state doesn't report readiness; exact supported client/features and deferred work are visible. Gate O passes only when the required recovery and compatibility evidence exists. Hosting other people is a separate scope decision after this release.
12. Verification harness and release evidence #
Use gleam format, gleam check, and gleam test during development. Before recording a completed code checkpoint, run gleam format --check plus the relevant compiler/tests and the card's behavior checks. Run an independent integration check when the feature changes a wire contract or external interoperability. Don't repeatedly run unrelated expensive checks after their result is established unless the changes invalidate it.
At M5.3 create an integration/ test package with exact versions and a lockfile for the reference libraries actually used. Record Node/runtime requirements independently from the Gleam runtime. Expose reproducible scripts with stable purposes, such as:
| Script name | Input | Required assertion/report |
|---|---|---|
verify:tree |
Paths and record values/CIDs; expected root | Reference encoding/MST root agrees with ours |
verify:repo |
CAR path, expected DID, public key | All relevant hashes, tree structure, signature, record mapping, revision verify |
verify:stream |
PDS URL, cursor, expected public DID/key | Parse binary frames; apply/invert diffs; reconstruct expected head |
verify:oauth |
Test HTTPS origin and registered client metadata | Discovery → PAR → consent → token → write → refresh → revoke |
verify:client |
Dedicated test account, selected client/network | Sanitized endpoint inventory and actual feature observations |
verify:restore |
Protected test backup and empty destination | Same identity/head/media and coherent restored runtime state |
Implement only the script needed by the current card. An integration/README.md specifies command line, setup, optional credentials through protected inputs, outputs, and failure exit codes. These script names are contracts for tools we will create, not commands that already exist. Install actual package APIs after reading their pinned exports; don't paste an unverified guessed script into this guide and claim it works.
Create docs/ACCEPTANCE.md as evidence accumulates:
# Acceptance evidence
Release/code checkpoint:
Gleam/OTP and dependency lock versions:
Protocol/Lexicon/fixture revisions:
Client/version or observation date:
Relay and AppView:
| Gate/check | Actual command or observation | Expected | Actual | Result | Limitation |
| --- | --- | --- | --- | --- | --- |
For each required fixture family, record case counts and unsupported cases. For failure-injection tests, name the injected boundary and verify both stored state and public output. For external indexing, record the public URI/query and actual observed result. For restore, record a fresh-instance exercise. Avoid claims such as "tests pass" without identifying whether those were unit, fixture, SDK, or client checks.
Minimal test coverage required before the corresponding gate:
| Area | Positive coverage | Required negative/failure coverage |
|---|---|---|
| Syntax | All supported identifier fixtures and boundaries | Wrong grammar, lengths, separators, normalization |
| Values/encoding | Fixture bytes, links/bytes, nested round trips | Float/invalid types, duplicates, malformed/truncated/oversized input |
| CID/block store | Expected digest/codec and text/binary formats | Wrong hash, encoding, length, same CID with different bytes |
| MST | Exact official roots and order independence | Empty/deletion/intermediate-layer cases, malformed nodes/missing context |
| Commit/CAR | Cross-implementation signature and mapping | Wrong DID/key/root, hash/signature failure, partial/corrupt archive |
| Auth | Actual password/OAuth/service flows | Role confusion, expiry/replay, wrong audience/key/method, missing permission |
| Writes | Single and ordered atomic batches | Conflicts, ownership, schema/size failures, rollback and concurrent writes |
| Blobs | Upload/reference/read and shared references | Other-owner/missing/temporary blob, partial install, GC race |
| Stream | Verified diffs, restart/replay and final head | Cursor cases, initialization race, slow subscriber, lost notification |
| Client | Actual selected-client login/write/media/read/preferences | Missing endpoint, incorrect CORS/scope/audience, upstream failures |
| Operations | Target-platform start, coherent backup/restore/migration | Native-library mismatch, bad config, crash/cutover recovery |
13. Troubleshooting recipes #
Treat these as investigation order, not instructions to disable correctness checks until something works.
| Symptom | Inspect in this order | Do not do |
|---|---|---|
| Package resolution fails | Exact failing URL; available network/DNS; locked versions; tool capability | Rename packages or weaken types to compensate for network failure |
| Gleam example doesn't compile | Installed package version, import/export names, current type signatures, FFI representation | Assume old Mist/Wisp examples still match |
| Server exits immediately | Main lifetime, listener startup result, supervision/application init | Hide failure with an unrelated long sleep |
| Wrong record CID | Exact record bytes, null/absence, key ordering, UTF-8 lengths, link tag, hash input and codec | Change expected fixture CIDs |
| Wrong MST root | Confirm record CIDs first; key ordering/layers; prefix compression; required null fields; intermediate layers | Add arbitrary balancing or flatten empty layers |
| Signature rejected | Public key/curve; message versus digest; DER/raw width; low-S; signed field set | Compare signature bytes to another random signing run |
| CAR verifier rejects export | Root header; full reachable blocks; hash checks; ordering assumptions; missing tree versus external link | Treat a JSON dump as a CAR archive |
| Handle resolves but account isn't accepted | Reverse alsoKnownAs claim, correct DID document, PDS/key/service type, public HTTPS |
Assume DNS TXT alone establishes identity |
| JWT validates but write is denied | Token role, audience/subject, session/account status, actual granted action | Accept refresh/service tokens as normal account access |
| OAuth repeats nonce errors | Public request URI/method; nonce issuance/exposure/rotation; proof reuse; binding and client handling | Turn off DPoP or TLS verification |
| OAuth succeeds but client can't initialize | Requested/approved scopes, private preferences, missing local routes, service-auth/proxy audience, sanitized request inventory | Return placeholder successful responses for missing endpoints |
| Relay disconnects | WebSocket upgrade, binary two-object framing, required fields, message sizes, commit signature, proof context/inversion | Switch to JSON frames or omit verification metadata |
| Replay misses/duplicates a write | Exclusive cursor rule, persistent high-water mark, durable event transaction, subscription initialization race | Reset sequence numbers on restart |
| Post doesn't appear in AppView | Independent repo/stream verification, relay receipt, current public identity/status, actual AppView acceptance/indexing | Claim indexing from a successful createRecord response |
| Restored images are missing | Backup manifest, actual blob files, reference/ownership metadata, restore paths/permissions | Assume media is embedded in the repository CAR |
| Linux release fails from macOS build | Architecture, OTP/runtime compatibility, native SQLite artifacts, packaged priv files |
Copy incompatible NIFs and suppress loader errors |
For a protocol failure, collect the smallest public input, exact bytes/headers where relevant, expected contract/source revision, actual result, and a reproducible command. Redact keys/tokens/passwords before showing it. Add a regression test once the cause is known.
For an external dependency/policy limitation, continue checks that don't depend on it and record the missing condition precisely. Never mark its gate complete. A request for user input should identify the actual missing value/authority/capability, the prepared local result, and the work that depends on it.
14. Resumption and completion rules #
The next model can be started with this instruction:
Read
IMPLEMENTATION_PLAN.mdandLEARNING_LOG.mdif present. Follow the executor instructions and fixed decisions. Teach me the first incomplete lesson card, using one small exercise at a time. Keep the code runnable, verify the specified checks, and update the learning log. Don't assume the previous conversation is available.
If the learner says "continue," resume the logged next action. If no log exists, start M0.1. If code exists but logs are stale, inspect and verify the last documented checkpoint before deciding what has actually been completed. Do not regenerate a project over existing code.
If a file-read tool truncates the plan, read the remaining pages/line ranges; do not treat truncated output as the whole guide. On resumption, use the navigation index to reread executor/fixed-decision instructions, the log, the selected card, and the shared contract sections it depends on. Keep a compact checkpoint listing the card ID, governing decisions, files, checks/results, pending learner action, and next card. A context reset is not a reason to restart or skip gates.
If the learner asks for autonomous implementation of a card or feature, do the authorized implementation and verification, then explain what changed. Keep the lesson/gate records accurate; don't infer authorization for public deployment, DNS changes, or existing-account migration solely from a local coding request.
The plan itself can evolve, but an executor proposing a change must name the affected decision/card, give the evidence, and preserve the user's objective: Gleam learning plus a single disposable Bluesky-compatible PDS. Routine compatible dependency/API choices can be resolved from actual installed docs. A material scope change or unresolved external choice needs direction.
The first operational release is complete only when gates L, R, S, C, and O have their required evidence, the known limitations are explicit, and the documented next step doesn't conceal required unfinished work. Course checkpoints can end much earlier when a learner exercise is pending; leave the exact next action and continue when the learner is ready.
This is a detailed execution plan, not a completed implementation or a substitute for checked versioned protocol contracts. Its setup examples are explicit; evolving library/wire details have named retrieval tasks, file routes, required contract notes, and independent checks. The executing model must obtain that evidence rather than fill missing facts from confidence.